Skip to content

Conversation

chloerice
Copy link
Member

@chloerice chloerice commented Dec 18, 2019

Reverts the revert of #2160.

The initial approach to knowing when to render the emptyState when provided used an existing prop, hasMoreItems, which conflicted with its use by consumers like web to show empty search result markup. That prop is also used internally to determine whether or not to show a "Load more items" link-like button in the list.

Initially this PR was adding a new boolean prop, showEmptyState so consumers could explicitly tell the ResourceList whether or not to render the emptyState when provided. After discussing with @dleroux, we've decided that emptyState will render when set if items are empty*.

*emptySearchState is a new prop recently added that complicates this a bit, as no results state is handled internally in spite of filters being a separate component. We have no idea when the list is being filtered/queried and since assumptions were already being made about no results existing when items is empty and loading is false, emptyState has to take precedence in that internal conditional in order to delineate between empty and no results states.

WHY are these changes introduced?

Right now our empty states are vastly different from our loading and with-data states. We'd like to make the transition from loading to empty state smoother, as well as make the with-data state more familiar once a feature has been used. For list views, this means having our empty states within the context of the resource list.

WHAT is this pull request doing?

Currently, ResourceList only has an empty state for when there are no results for a filter or search query. This PR adds support for providing markup to render when there are not yet any resources to list. That way the same static content can be rendered in a loading, empty, and with-data state. See the tophatting instructions to view the smooth visual transition between these states using the playground code.

Screen Shot 2020-05-13 at 5 47 30 PM

How to 🎩

🖥 Local development instructions
🗒 General tophatting guidelines
📄 Changelog guidelines

  • git checkout refactor-and-revert-revert-resourcelist-emptystate
  • Copy and paste the playground code below into playground/Playground.tsx
  • yarn dev to run and open Storybook locally
  • Click "Playground" in the left navigation of Storybook and use the page's secondary actions to toggle between loading, empty, and with-data states.
    • When in a with data state, if searching or filtering yields no results the EmptySearchResult should render (try searching for zebra, or filtering for the JPEG file type.)
Copy-paste this code in playground/Playground.tsx:
import React, {useState, useCallback} from 'react';
import {
  Page,
  Layout,
  ResourceList,
  ResourceItem,
  EmptyState,
  Card,
  TextStyle,
  Select,
  Filters,
  Thumbnail,
  Tabs,
  SkeletonBodyText,
  Spinner,
} from '../src';

const allItems = [
  {
    id: 341,
    url: 'files/341',
    name: 'Point of sale staff',
    imgSource:
      'https://user-images.githubusercontent.com/29233959/61383074-fc5e6800-a87b-11e9-8e92-487153efd0dc.png',
    fileType: 'png',
  },
  {
    id: 256,
    url: 'files/256',
    name: 'File upload',
    imgSource:
      'https://user-images.githubusercontent.com/18447883/59945230-fa4be980-9434-11e9-9106-a373f0efbe08.png',
    fileType: 'png',
  },
];

const tabs = [
  {
    id: 'all-files',
    content: 'All',
    accessibilityLabel: 'All files',
    panelID: 'all-files-content',
  },
];

export function Playground() {
  const [viewLoadingState, setLoadingState] = useState(false);
  const [viewEmptyState, setEmptyState] = useState(true);
  const [filteredItems, setFilteredItems] = useState([]);
  const [fileType, setFileType] = useState('');
  const [queryValue, setQueryValue] = useState('');

  const toggleLoadingState = useCallback(
    () => setLoadingState((viewLoadingState) => !viewLoadingState),
    [],
  );

  const toggleEmptyState = useCallback(
    () => setEmptyState((viewEmptyState) => !viewEmptyState),
    [],
  );

  const updateFilteredItems = useCallback((items) => {
    setFilteredItems(items);
  }, []);

  const updateFileTypeFilter = useCallback(
    (value) => {
      setFileType(value);
      const itemsToFilter =
        queryValue && filteredItems.length > 0 ? filteredItems : allItems;
      updateFilteredItems(filterByFileType(value, itemsToFilter));
    },
    [filteredItems, queryValue, updateFilteredItems],
  );

  const updateQueryValue = useCallback(
    (value) => {
      setQueryValue(normalizeString(value));
      const itemsToFilter =
        fileType && filteredItems.length > 0 ? filteredItems : allItems;

      updateFilteredItems(
        filterByQueryValue(normalizeString(value), itemsToFilter),
      );
    },
    [fileType, filteredItems, updateFilteredItems],
  );

  const handleRemove = useCallback(() => {
    updateFileTypeFilter('');
  }, [updateFileTypeFilter]);

  const handleQueryClear = useCallback(() => {
    updateQueryValue('');
  }, [updateQueryValue]);

  const handleClearAll = useCallback(() => {
    updateFileTypeFilter('');
    updateQueryValue('');
  }, [updateFileTypeFilter, updateQueryValue]);

  let items = fileType || queryValue ? filteredItems : allItems;

  if (viewEmptyState) {
    items = [];
  }

  const filters = [
    {
      key: 'fileType',
      label: 'File type',
      filter: (
        <Select
          labelHidden
          label="File type"
          value={fileType}
          placeholder="Select a file type"
          options={[
            {label: 'JPEG', value: 'jpeg'},
            {label: 'PNG', value: 'png'},
            {label: 'MP4', value: 'mp4'},
          ]}
          onChange={updateFileTypeFilter}
        />
      ),
      shortcut: true,
    },
  ];

  const appliedFilters = fileType
    ? [
        {
          key: 'fileType',
          label: `File type ${fileType.toUpperCase()}`,
          onRemove: handleRemove,
        },
      ]
    : [];

  const filterControl = (
    <Filters
      disabled={viewEmptyState}
      queryValue={queryValue}
      filters={filters}
      appliedFilters={appliedFilters}
      onQueryChange={updateQueryValue}
      onQueryClear={handleQueryClear}
      onClearAll={handleClearAll}
    />
  );

  const emptyStateMarkup = (
    <EmptyState
      heading="Upload a file to get started"
      action={{content: 'Upload files'}}
      image="https://cdn.shopify.com/s/files/1/2376/3301/products/emptystate-files.png"
    >
      <p>
        You can use the Files section to upload images, videos, and other
        documents
      </p>
    </EmptyState>
  );

  const loadingStateMarkup = viewLoadingState ? (
    <Card>
      <SkeletonTabs />

      <div>
        <div style={{padding: '16px', marginTop: '1px'}}>
          <Filters
            disabled={viewEmptyState}
            queryValue={queryValue}
            filters={filters}
            appliedFilters={appliedFilters}
            queryPlaceholder="Filter files by keyword"
            onQueryChange={updateQueryValue}
            onQueryClear={handleQueryClear}
            onClearAll={handleClearAll}
          />
        </div>
        <div
          style={{
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            minHeight: '160px',
          }}
        >
          <Spinner />
        </div>
      </div>
    </Card>
  ) : null;

  const withDataStateMarkup = !viewLoadingState ? (
    <Card>
      <Tabs selected={0} tabs={tabs}>
        <ResourceList
          showHeader   
          showEmptyState={viewEmptyState}
          emptyState={emptyStateMarkup}
          resourceName={{singular: 'file', plural: 'files'}}
          items={items}
          renderItem={renderItem}
          filterControl={filterControl}
        />
      </Tabs>
    </Card>
  ) : null;

  return (
    <Page
      title="Files"
      secondaryActions={[
        {
          content: 'Toggle empty state',
          onAction: toggleEmptyState,
          disabled: viewLoadingState,
        },
        {
          content: 'Toggle loading state',
          onAction: toggleLoadingState,
        },
      ]}
      breadcrumbs={[{content: 'Home', url: '/'}]}
    >
      <Layout>
        <Layout.Section>
          {loadingStateMarkup}
          {withDataStateMarkup}
        </Layout.Section>
      </Layout>
    </Page>
  );
}

function renderItem(item) {
  const {id, url, name, imgSource, altText = '', fileType} = item;
  const media = <Thumbnail alt={altText} source={imgSource} />;
  return (
    <ResourceItem id={id} url={url} media={media}>
      <h3>
        <TextStyle variation="strong">{name}</TextStyle>
      </h3>
      <div>{fileType}</div>
    </ResourceItem>
  );
}

function filterByFileType(fileType, items) {
  return items.filter((item) => item.fileType === fileType);
}

function filterByQueryValue(query, items) {
  return items.filter((item) => {
    return (
      normalizeString(item.name).includes(query) ||
      normalizeString(item.fileType).includes(query) ||
      normalizeString(item.imgSource).includes(query)
    );
  });
}

function normalizeString(string) {
  return string.toLowerCase();
}

function SkeletonTabs() {
  return (
    <div
      style={{
        width: '100%',
        display: 'flex',
        borderBottom: '1px solid #DFE3E8',
        height: '53px',
      }}
    >
      <div
        style={{
          width: '80px',
          padding: '21px 20px',
        }}
      >
        <SkeletonBodyText lines={1} />
      </div>
    </div>
  );
}

🎩 checklist

@github-actions
Copy link
Contributor

github-actions bot commented Dec 18, 2019

🟢 This pull request modifies 5 files and might impact 1 other files.

Details:
All files potentially affected (total: 1)
📄 UNRELEASED.md (total: 0)

Files potentially affected (total: 0)

🎨 src/components/EmptyState/EmptyState.scss (total: 1)

Files potentially affected (total: 1)

📄 src/components/ResourceList/README.md (total: 0)

Files potentially affected (total: 0)

🧩 src/components/ResourceList/ResourceList.tsx (total: 0)

Files potentially affected (total: 0)

🧩 src/components/ResourceList/tests/ResourceList.test.tsx (total: 0)

Files potentially affected (total: 0)

@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch from 870363b to 57f323e Compare December 20, 2019 19:31
@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch from 57f323e to abec7af Compare January 8, 2020 20:37
@chloerice chloerice requested a review from dleroux January 8, 2020 22:19
@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch from d970f85 to 588d37a Compare January 8, 2020 22:34
}

.withinContentContainer {
margin: 0 auto;
Copy link
Member Author

@chloerice chloerice Jan 8, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Oversight, top margin should've been removed from the .EmptyState class in a content context in #1570)

@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch 4 times, most recently from 703c101 to 76eb617 Compare January 8, 2020 22:46
@dleroux
Copy link
Contributor

dleroux commented Jan 20, 2020

Hi @chloerice. I know this isn't necessarily a breaking change, but I'm wondering if we should target v5 for this. The reason is that this will require consumers to update their code to pass the empty state, which could be a potential blocker from releasing in web. Alternatively, can we find out how many lists will be affected and reach out to the owners?

The code looks good but have we considered making emptyState optional and when passed it gets rendered regardless?

@chloerice
Copy link
Member Author

chloerice commented Jan 21, 2020

Hi @chloerice. I know this isn't necessarily a breaking change, but I'm wondering if we should target v5 for this. The reason is that this will require consumers to update their code to pass the empty state, which could be a potential blocker from releasing in web. Alternatively, can we find out how many lists will be affected and reach out to the owners?

The code looks good but have we considered making emptyState optional and when passed it gets rendered regardless?

Hey @dleroux! ResourceList.emptyState is totally optional, purely an enhancement. So sections will be able to implement the new pattern when the changes in this PR ship + release, but it in no way forces empty states to be handled in this new pattern.


const showEmptyState = filterControl && !this.itemsExist() && !loading;
const showEmptyStateMarkup =
!loading && showEmptyState && emptyState && !this.itemsExist();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like it could lead to confusion to allow the consumer to determine whether they should show the empty state and then internal logic could override that. What do you think about showEmptyState taking precedence over everything else? Or even if an emptyState is defined show the empty state?

Copy link
Member Author

@chloerice chloerice Jan 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if an emptyState is defined show the empty state?

Ahhh that's what you meant in the other comment! That makes sense, consumers can just use the conditional they'd use to set the value of showEmptyState as a ternary condition for setting the value of emptyState. I'll refactor and update the example and prop description 👍

@dleroux
Copy link
Contributor

dleroux commented Jan 27, 2020

🎩 looks good, just left a comment about the behavior of the prop.

@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch 3 times, most recently from 7d00e86 to b361473 Compare May 13, 2020 22:04
@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch 3 times, most recently from 5bba223 to 702877c Compare May 13, 2020 22:35
@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch from 702877c to e371810 Compare May 13, 2020 22:49
@chloerice chloerice requested review from alex-page and dleroux May 13, 2020 22:56
@chloerice chloerice force-pushed the refactor-and-revert-revert-resourcelist-emptystate branch from e371810 to 57a40ac Compare May 13, 2020 23:11
@alex-page alex-page requested review from dleroux and removed request for dleroux May 14, 2020 00:15
filterControl?: React.ReactNode;
/** The markup to display when no resources exist yet. Renders when set and items is empty. */
emptyState?: React.ReactNode;
/** The markup to display when no results are returned on search or filter of the list. Renders when `filterControl` is set, items are empty, and `emptyState` is not set.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could someone have filterControls on a list that has not had resources yet?

Copy link
Member Author

@chloerice chloerice May 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! You can see what that looks like in the playground code--aside from making list views more familiar to new merchants, the reason for moving to in-context empty states is for smooth transition between loading and empty or with-data states 😊. That's why I've got the filterControl set in the empty state example to illustrate that you'd want to still have the filters there but just disable them since there's no list items to take action on.

/** The markup to display when no results are returned on search or filter of the list. Renders when `filterControl` is set, items are empty, and `emptyState` is not set.
* @default EmptySearchResult
*/
emptySearchState?: React.ReactNode;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are new props. what will happen to the empty list in web that don't have these props defined?

Copy link
Member Author

@chloerice chloerice May 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're optional props so there's no breaking changes with either. Andre added emptySearchState because he needed to render custom markup for the no results state instead of EmptySearchResult for the resource list he's working on. Empty state for the list wasn't handled/supported before this PR, the list would just be empty and have no header. But with the new design language rollout we'll be changing empty states for list views from having the large EmptyState illustration to having the empty state be in the context of the list so that it's more familiar to merchants once the list has resources.

@dleroux
Copy link
Contributor

dleroux commented May 14, 2020

Just top hatted in web and It looks like it working fine. The logic is getting a little complex, I wonder if explicitly having showEmptyState and showEmptySearchResult would be simpler. Either way this works.

@chloerice chloerice merged commit f4ca79f into master May 14, 2020
@chloerice chloerice deleted the refactor-and-revert-revert-resourcelist-emptystate branch May 14, 2020 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants