Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pd 12487 collapsible checklist #123

Merged
merged 20 commits into from Jul 18, 2019
Merged

Conversation

jamiek-acl
Copy link
Contributor

@jamiek-acl jamiek-acl commented Jul 15, 2019

🛠 Purpose

Create 'CollapsibleChecklists' component.
Can have optional headings.
Can disable parent or child.
Clicking checkbox of parent selects all children.
Clicking a parent or child fires a callback, passing in the children to change.
Labels of parent and child can be any node (not just a string).


✏️ Notes to Reviewer


🖥 Screenshots

Screen Shot 2019-07-16 at 2 26 39 PM


@jamiek-acl jamiek-acl force-pushed the pd-12487-collapsible-checklist branch from fa0fac7 to 52f57fb Compare July 15, 2019 23:43
@@ -7,7 +7,7 @@
"main": "lib/index.js",
"repository": {
"type": "git",
"url": "https://github.com/acl-services/paprika/tree/master/packages/collapsible"
"url": "https://github.com/acl-services/paprika/tree/master/packages/Collapsible"
Copy link
Contributor

@nahumzs nahumzs Jul 16, 2019

Choose a reason for hiding this comment

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

I think we should use homepage for this value and url property should point to the repo itself.

read more: #91

@jamiek-acl jamiek-acl marked this pull request as ready for review July 16, 2019 21:33
@@ -0,0 +1,41 @@
import React from "react";
import PropTypes from "prop-types";
import Heading from "./components/Heading";
Copy link
Contributor

Choose a reason for hiding this comment

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

should import paprika components instead of relative paths

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh its not a paprika Heading, its my own

Copy link
Contributor

Choose a reason for hiding this comment

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

Wondering if perhaps we should try to not call our own components the same as paprika component names in general to avoid that confusion

{ isChecked: false, isDisabled: false, name: "Cleveland Browns" },
];
setSportsData(newSportsData);
}, 2000);
Copy link
Contributor

Choose a reason for hiding this comment

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

create a static variable for this number eg const EXPAND_DURATION=2000 🤷‍♂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's just a story; think that is necessary?

Copy link
Contributor

Choose a reason for hiding this comment

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

🤷‍♂

}

function renderOhioFootballTeams() {
if (sportsData[1].sports[0].teams.length === 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

perhaps create variables for the items with namings like const firstSport = sports[1].sports[0] so that can avoid setting the array index in multiple places and easy to follow

```js
import CollapsibleChecklists from "@paprika/collapsible-checklists";

const yourComponent = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const yourComponent = () => {
const YourComponent = () => {

it("cannot expand a disabled group", () => {
const { getByText, container } = renderComponent();
expect(getByText(/easter/i)).not.toBeVisible();
fireEvent.click(container.querySelectorAll(".collapsible__label")[2]); // Lilies
Copy link
Contributor

Choose a reason for hiding this comment

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

That class name shouldn't exist though?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great catch

{children.map((child, index) => {
switch (child.type.displayName) {
case Heading.displayName:
return <Heading key={`heading${index}`} {...child.props} />; // eslint-disable-line react/no-array-index-key
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the key necessary? I think keys should be handled by the consumer. Keys represent the "identity" of elements, only consumer can know that. So this can just return child

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The key is necessary for React console warning:

Warning: Each child in a list should have a unique "key" prop.

Copy link
Contributor

Choose a reason for hiding this comment

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

See if that's still there if you just return child itself?
It should also disappear for the Group case if you use React.cloneElement since that should preserve keys.

return (
<React.Fragment>
{children.map((child, index) => {
switch (child.type.displayName) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure what others think about using displayName here. Just checking child.type should work; it used to be broken by react-hot-loader but apparently that's not the case anymore? gaearon/react-hot-loader#304 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we count on the type not being changed during transpilation / minification?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes that identity should be preserved. Especially when there is no hot reloading involved, there should be nothing to worry about AFAIK.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can remove the .displayName part

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm kind of hesitant to recommend this though since that would require everyone to use the latest react-hot-loader... thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

How many apps are using the latest react-hot-loader vs the broken one?

Since there are a few components now using this .displayName approach, I'm leaning towards keeping it for now, not creating issues in our apps, and capturing this possible refactor in an issue which we can revisit when at least most of our apps have updated their 🔥 loaders.

Copy link
Contributor

Choose a reason for hiding this comment

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

Some of them are not using react-hot-loader at all...

Sounds good. Looks like refactoring this will just be a semver-patch.

case Heading.displayName:
return <Heading key={`heading${index}`} {...child.props} />; // eslint-disable-line react/no-array-index-key
case Group.displayName:
return <Group key={`group${index}`} {...child.props} onChange={onChange} />; // eslint-disable-line react/no-array-index-key
Copy link
Contributor

Choose a reason for hiding this comment

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

This should use React.cloneElement

onChange={toggleChildren}
aria-label={textContent(title)}
/>
{title}
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably think about how to associate whatever the title is with the checkbox for accessibility @mikrotron

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, that's what Jamie's trying to do with textContent(title), but as mentioned, that won't really work without rendering the node. Even the library Jamie is using will probably not work for us in all situations:

rwu823/react-addons-text-content#1 (comment)

So yeah, we should probably require a string version from the consumer as an a11yText prop.

Copy link
Contributor

Choose a reason for hiding this comment

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

... or ... maybe if there was a visuallyHidden <span id={someUID}>{title}</span>, and then we could have an aria-labelledby={someUID} on the input ... 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

But that wouldn't satisfy the requirement of checking the checkbox via clicking the label? Do we still want that?

Copy link
Contributor

Choose a reason for hiding this comment

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

Excellent point! In general clicking the label should activate the checkbox since that's such a small click target... it as a big impact on a11y! In this specific case though, I wonder... there's not an easy "undo" to the select all / deselect all operation of this checkbox, and maybe it's better to confine it to the actual checkbox. It could easily be assumed by the user that the title in this case would actually expand the Collapsible, and then they'd be frustrated.

I think we need a @krismckinnonacl for this one!

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree with all the discussion here, but I think this can be addressed in another version, this is not the part of the API so we could refactor it easily in a bump it.

Can we create an issue with all the followed steps from this review @jamiek-acl and include this one?

Copy link

Choose a reason for hiding this comment

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

I would make clicking the label activate that checkbox. For the following reasons:

  1. Assuming that the child entries underneath work normally, if we did make that change, we'd be changing the behaviour of an element that looks visually the same, in close proximity to other controls that do the same thing, in a subtle way - I assume that clicking on the titles of the items underneath would check the associated checkbox, right?
  2. If the user does in fact assume that clicking the title expands or collapses the section but instead selects all the elements, it's an easy mistake to correct, because they will encounter it BEFORE they select items underneath. It would be atypical for someone to
    1. click the caret, then
    1. select some things, then
    1. click the caret to collapse again then
    1. come back later and click the title instead of where they clicked before to open the elements.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Yes checking the titles of the children toggles them, too.
  2. No it's not easily undone. If some children are selected and i click the parent, that would select all of its children. If you didn't intend that and wanted to undo, you wouldn't know which ones to de-select.

if (isCollapsed && onExpand) {
onExpand();
}
setIsCollapsed(!isCollapsed);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
setIsCollapsed(!isCollapsed);
setIsCollapsed(prevIsCollapsed => !prevIsCollapsed);

https://reactjs.org/docs/hooks-reference.html#functional-updates

return (
<CollapsibleChecklists onChange={handleOnChange}>
<CollapsibleChecklists.Heading>California Sports Teams</CollapsibleChecklists.Heading>
<CollapsibleChecklists.Group title={sportsData[0].sports[0].title}>
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it'd be more representative of the real usage if this was created in a loop

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm trying to keep it as simple as possible; I want to demonstrate to future onlookers that the API looks like this:

    <CollapsibleChecklists onChange={() => {}}>
      <CollapsibleChecklists.Heading>Flowers</CollapsibleChecklists.Heading>
      <CollapsibleChecklists.Group title="Roses">
        <CollapsibleChecklists.Item isChecked>Damask</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item isChecked>Eden</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item isChecked>Lady Banks</CollapsibleChecklists.Item>
      </CollapsibleChecklists.Group>

      <CollapsibleChecklists.Group title="Irises">
        <CollapsibleChecklists.Item isChecked>Siberian</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Yellow</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item isDisabled>Bearded</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Netted</CollapsibleChecklists.Item>
      </CollapsibleChecklists.Group>

      <CollapsibleChecklists.Group title="Lilies" isDisabled>
        <CollapsibleChecklists.Item>Easter</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Madonna</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Stargazer</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Tiger</CollapsibleChecklists.Item>
        <CollapsibleChecklists.Item>Turks Cap</CollapsibleChecklists.Item>
      </CollapsibleChecklists.Group>
    </CollapsibleChecklists>

not have them spend time deciphering what all the loops are doing.

childItemsToChange = childItems.filter(childItem => !childItem.props.isChecked && !childItem.props.isDisabled);
}

onChange(childItemsToChange);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm interested in hearing others' feedback about this. While discussing the API for <Sortable> with @mikrotron I said that an onChange API based on React elements doesn't make a lot of sense to me. In this case, given a set of React elements, how would the consumer know what to do? They would need to somehow map those elements to their actual data to determine what is being checked/unchecked. The storybook story doesn't really handle this, so it's not obvious that there's an issue.

We might want to require some kind of id prop on the items to handle this. (can't use key because that wouldn't be visible to us)

Copy link
Contributor

Choose a reason for hiding this comment

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

Since we expect an array, maybe just the indexes would be sufficient?

Copy link
Contributor

@nahumzs nahumzs Jul 17, 2019

Choose a reason for hiding this comment

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

The idea is that you always received the selectedIndexes as the first argument, and we could pass the full list of elements as second argument so you could do:

function handleChange(indexes, list) {
  indexes.forEach(index => console.log(list[index].props.value))
}

where value can be any property they want to access, can be id, data, etc...

of course, if we want to get the ones that haven't been selected we would need to use filter instead of map or forEach.

Copy link
Contributor

Choose a reason for hiding this comment

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

We only have a single onChange prop for the entire component now, so a single index would not be enough to describe a child checkbox, you'd also need an index of the group. (Alternatively, put a separate onChange prop on each group—which might be too repetitive though? Realistically a consumer is more likely to define a single callback handler for this component anyway I think?)

For the snippet above, seems like the list is redundant? The consumer is the one passing in those "values" in the first place, so they don't really need to be given them back. They already have some kind of state that they can iterate over.

I suggest creating a story that would better reflect the real world usage of this component (i.e. resembling the feature that will need it). That should make it easier to design.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The consumer can compare on anything... I chose to compare by key (as I set key={team.name} but could have just as easily called it foo={team.name}. In fact i'll update the example to use foobar, and the comparison checks props.foobar

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, I'm -1 on this.

@@ -0,0 +1,3 @@
import CollapsibleChecklists from "./CollapsibleChecklists";
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
import CollapsibleChecklists from "./CollapsibleChecklists";
export { default } from "./CollapsibleChecklists";

@jamiek-acl jamiek-acl merged commit 0383039 into master Jul 18, 2019
@jamiek-acl jamiek-acl deleted the pd-12487-collapsible-checklist branch July 18, 2019 16:28
@alexzherdev
Copy link
Contributor

@nahumzs this was merged too soon IMO. The reason I was asking for a real-world story is that I believe the current API will not work. Here's roughly how you'll have to use it in a loop:

<CollapsibleChecklists onChange={handleOnChange}>
  {sections.map(({ heading, items }) => (
    <React.Fragment key={heading}>
      <CollapsibleChecklists.Heading>{heading}</CollapsibleChecklists.Heading>
      <CollapsibleChecklists.Group>...</CollapsibleChecklists.Group>
    </React.Fragment>
  )}
</CollapsibleChecklists>

This doesn't work because we are forEaching through <CollapsibleChecklists>' children to find <Group>s to clone and we won't find any here.

Additionally, since we're fiddling with keys, we'd want a story where a consumer has their own keys on components to be sure we haven't broken anything for that case.

@jamiek-acl
Copy link
Contributor Author

@alexzherdev why is the story that is included not a real-world story?

@alexzherdev
Copy link
Contributor

Probably not worded well enough—I think subjectively components like this are more often data-driven than not? Meaning you'll have a collection of state to loop over (perhaps coming from an API), rather than hard code individual data pieces in. Even if we assume it's 50/50, still we want the component to work in those other 50% of cases.

@jamiek-acl
Copy link
Contributor Author

The existing story simulates:

  • grabbing some of the data from an API (the Ohio Football group)
  • looping over that data and maintaining state of selected items/groups of items
  • disabled items
  • disabled groups
  • headings
  • items that are simple strings
  • items that are nodes

Check this video of the Storybook story that demonstrates those things, plus keyboard a11y:
use2

@alexzherdev
Copy link
Contributor

Ah, I didn't mean the actual end customer usage in the browser—that is perfectly covered in the story like that video demonstrates. I meant the usage of the component by developers consuming it in application code. The story code doesn't reflect the case when headings+groups are in an array (grabbed from an API) that has to be looped over, like in my snippet above. And if it did, you'd find that it doesn't work (unless I'm totally missing something).

React.Children.forEach(children, (child, index) => {
switch (child.type.displayName) {
case Group.displayName:
modifiedChildren.push(React.cloneElement(child, { key: `group${index}`, onChange })); // eslint-disable-line react/no-array-index-key
Copy link
Contributor

Choose a reason for hiding this comment

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

That ESLint rule speaks the truth. Even if you're not explicitly reordering items a la <Sortable>, when an item gets removed from the middle of the list things will break. https://reactjs.org/docs/lists-and-keys.html#keys

@jamiek-acl
Copy link
Contributor Author

@alexzherdev I've added another story to demonstrate grabbing headings and groups from an API, that Nahum created the other day: #134

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.

None yet

7 participants