-
Notifications
You must be signed in to change notification settings - Fork 174
Implement collection attacher component. #3599
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
Changes from all commits
b540de0
6bebffe
dfa136b
25da4a1
2f9f80b
462c127
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import React, { Key, ReactNode } from 'react'; | ||
| import { | ||
| Badge, | ||
| Button, | ||
| EmptyState, | ||
| EmptyStateIcon, | ||
| EmptyStateVariant, | ||
| Flex, | ||
| FormGroup, | ||
| Title, | ||
| } from '@patternfly/react-core'; | ||
| import { CubesIcon, MinusCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; | ||
|
|
||
| import { | ||
| BaseCellProps, | ||
| TableComposable, | ||
| TableVariant, | ||
| Tbody, | ||
| Td, | ||
| Tr, | ||
| } from '@patternfly/react-table'; | ||
|
|
||
| type BacklogTableProps<Item> = { | ||
| type: 'selected' | 'deselected'; | ||
| label: string | undefined; | ||
| items: Item[]; | ||
| listAction: (item: Item) => void; | ||
| rowKey: (item: Item) => Key; | ||
| cells: { | ||
| name: string; | ||
| render: (item: Item) => ReactNode; | ||
| width?: BaseCellProps['width']; | ||
| }[]; | ||
| buttonText: string; | ||
| showBadge: boolean; | ||
| }; | ||
|
|
||
| function BacklogTable<Item>({ | ||
| type, | ||
| label, | ||
| items, | ||
| listAction, | ||
| rowKey, | ||
| cells, | ||
| buttonText, | ||
| showBadge, | ||
| }: BacklogTableProps<Item>) { | ||
| const actionIcon = | ||
| type === 'selected' ? ( | ||
| <MinusCircleIcon color="var(--pf-global--danger-color--200)" /> | ||
| ) : ( | ||
| <PlusCircleIcon color="var(--pf-global--primary-color--100)" /> | ||
| ); | ||
| return ( | ||
| <FormGroup | ||
| label={ | ||
| <> | ||
| {label} | ||
| {showBadge && ( | ||
| <Badge className="pf-u-ml-sm" isRead> | ||
| {items.length} | ||
| </Badge> | ||
| )} | ||
| </> | ||
| } | ||
| > | ||
| {items.length > 0 ? ( | ||
| <TableComposable aria-label={label} variant={TableVariant.compact}> | ||
| <Tbody> | ||
| {items.map((item) => ( | ||
| <Tr key={rowKey(item)}> | ||
| {cells.map(({ name, width, render }) => ( | ||
| <Td key={name} dataLabel={name} width={width}> | ||
| {render(item)} | ||
| </Td> | ||
| ))} | ||
| <Td width={10} dataLabel="Item action"> | ||
| <Button | ||
| variant="link" | ||
| onClick={() => listAction(item)} | ||
| icon={actionIcon} | ||
| className="pf-u-text-nowrap" | ||
| isInline | ||
| > | ||
| {buttonText} | ||
| </Button> | ||
| </Td> | ||
| </Tr> | ||
| ))} | ||
| </Tbody> | ||
| </TableComposable> | ||
| ) : ( | ||
| <EmptyState variant={EmptyStateVariant.xs}> | ||
| <EmptyStateIcon icon={CubesIcon} /> | ||
| <Title headingLevel="h4">No items remaining</Title> | ||
| </EmptyState> | ||
| )} | ||
| </FormGroup> | ||
| ); | ||
| } | ||
|
|
||
| export type BacklogListSelectorProps<Item> = { | ||
| selectedOptions: Item[]; | ||
| deselectedOptions: Item[]; | ||
| onSelectItem: (item: Item) => void; | ||
| onDeselectItem: (item: Item) => void; | ||
| onSelectionChange?: (selected: Item[], deselected: Item[]) => void; | ||
| rowKey: (item: Item) => Key; | ||
| cells: { | ||
| name: string; | ||
| render: (item: Item) => ReactNode; | ||
| }[]; | ||
| selectedLabel?: string; | ||
| deselectedLabel?: string; | ||
| selectButtonText?: string; | ||
| deselectButtonText?: string; | ||
| showBadge?: boolean; | ||
| }; | ||
|
|
||
| function BacklogListSelector<Item>({ | ||
| selectedOptions, | ||
| deselectedOptions, | ||
| onSelectItem, | ||
| onDeselectItem, | ||
| onSelectionChange = () => {}, | ||
| rowKey, | ||
| cells, | ||
| selectedLabel = 'Selected items', | ||
| deselectedLabel = 'Deselected items', | ||
| selectButtonText = 'Add', | ||
| deselectButtonText = 'Remove', | ||
| showBadge = false, | ||
| }: BacklogListSelectorProps<Item>) { | ||
| function onSelect(item: Item) { | ||
| onSelectItem(item); | ||
| onSelectionChange( | ||
| selectedOptions.concat(item), | ||
| deselectedOptions.filter((option) => option !== item) | ||
| ); | ||
| } | ||
|
|
||
| function onDeselect(item: Item) { | ||
| onDeselectItem(item); | ||
| onSelectionChange( | ||
| selectedOptions.filter((option) => option !== item), | ||
| deselectedOptions.concat(item) | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Flex direction={{ default: 'column' }} spaceItems={{ default: 'spaceItemsXl' }}> | ||
| <BacklogTable | ||
| type="selected" | ||
| label={selectedLabel} | ||
| items={selectedOptions} | ||
| listAction={onDeselect} | ||
| buttonText={deselectButtonText} | ||
| rowKey={rowKey} | ||
| cells={cells} | ||
| showBadge={showBadge} | ||
| /> | ||
| <BacklogTable | ||
| type="deselected" | ||
| label={deselectedLabel} | ||
| items={deselectedOptions} | ||
| listAction={onSelect} | ||
| rowKey={rowKey} | ||
| buttonText={selectButtonText} | ||
| cells={cells} | ||
| showBadge={showBadge} | ||
| /> | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| export default BacklogListSelector; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,95 @@ | ||
| import React from 'react'; | ||
| import React, { useMemo, useState } from 'react'; | ||
| import { Alert, Button, debounce, Flex, SearchInput, Truncate } from '@patternfly/react-core'; | ||
|
|
||
| function CollectionAttacher() { | ||
| return <></>; | ||
| import BacklogListSelector from 'Components/PatternFly/BacklogListSelector'; | ||
| import { CollectionResponse } from 'services/CollectionsService'; | ||
| import useEmbeddedCollections from './hooks/useEmbeddedCollections'; | ||
|
|
||
| const selectorListCells = [ | ||
| { | ||
| name: 'Name', | ||
| render: ({ name }) => ( | ||
| <Button variant="link" className="pf-u-pl-0" isInline> | ||
| {name} | ||
| </Button> | ||
|
Comment on lines
+12
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Asking to confirm my understanding or misunderstanding:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes that is exactly correct. In a follow up I'll likely move the |
||
| ), | ||
| }, | ||
| { | ||
| name: 'Description', | ||
| render: ({ description }) => <Truncate content={description} />, | ||
| }, | ||
| ]; | ||
|
|
||
| export type CollectionAttacherProps = { | ||
| initialEmbeddedCollections: CollectionResponse[]; | ||
| onSelectionChange: (collections: CollectionResponse[]) => void; | ||
| }; | ||
|
|
||
| function compareNameLowercase(search: string): (item: { name: string }) => boolean { | ||
| return ({ name }) => name.toLowerCase().includes(search.toLowerCase()); | ||
| } | ||
|
|
||
| function CollectionAttacher({ | ||
| initialEmbeddedCollections, | ||
| onSelectionChange, | ||
| }: CollectionAttacherProps) { | ||
| const [search, setSearch] = useState(''); | ||
| const embedded = useEmbeddedCollections(initialEmbeddedCollections); | ||
| const { attached, detached, attach, detach, hasMore, fetchMore, onSearch } = embedded; | ||
| const { isFetchingMore, fetchMoreError } = embedded; | ||
|
|
||
| const onSearchInputChange = useMemo( | ||
| () => | ||
| debounce((value: string) => { | ||
| setSearch(value); | ||
| onSearch(value); | ||
| }, 800), | ||
| [onSearch] | ||
| ); | ||
|
|
||
| const selectedOptions = attached.filter(compareNameLowercase(search)); | ||
| const deselectedOptions = detached.filter(compareNameLowercase(search)); | ||
|
|
||
| return ( | ||
| <Flex direction={{ default: 'column' }} spaceItems={{ default: 'spaceItemsXl' }}> | ||
| <SearchInput | ||
| aria-label="Filter by name" | ||
| placeholder="Filter by name" | ||
| value={search} | ||
| onChange={onSearchInputChange} | ||
| /> | ||
| <BacklogListSelector | ||
| selectedOptions={selectedOptions} | ||
| deselectedOptions={deselectedOptions} | ||
| onSelectItem={({ id }) => attach(id)} | ||
| onDeselectItem={({ id }) => detach(id)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An observation, not a change request, that change in terminology from attach/detach to select/deselect is what I would expect for a reusable component, what do you think are pro and con:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm this is a good question. My gut feeling is that the default terminology for using this in other contexts would be "select/deselect". This component is similar to the dual list selector PF component which uses terms that are fairly close. I think the attach/detach are specific, but not exclusive, to the semantics behind Collections. That said it doesn't feel great having a disconnect between the props and the only use case. (The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your choice. You caught me: I often use Find in Files by mental muscle memory from less capable editors. |
||
| onSelectionChange={onSelectionChange} | ||
| rowKey={({ id }) => id} | ||
| cells={selectorListCells} | ||
| selectedLabel="Attached collections" | ||
| deselectedLabel="Detached collections" | ||
| selectButtonText="Attach" | ||
| deselectButtonText="Detach" | ||
| /> | ||
| {fetchMoreError && ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First impression gave me a much needed smile: fetch more error, please ;)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hahah now I can't read it any other way! Seriously though, do you think there is enough chance for confusion that we should change this to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It just hit me funny at that moment. In first reading a few days ago, it seemed nicely consistent. |
||
| <Alert | ||
| variant="danger" | ||
| isInline | ||
| title="There was an error loading more collections" | ||
| /> | ||
| )} | ||
| {hasMore && ( | ||
| <Button | ||
| className="pf-u-align-self-flex-start" | ||
| variant="secondary" | ||
| onClick={() => fetchMore(search)} | ||
| isLoading={isFetchingMore} | ||
| > | ||
| View more | ||
| </Button> | ||
| )} | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| export default CollectionAttacher; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you inspect table cells to see why centered alignment in responsive view?
Here is an example from /main/access-control/permission-sets for comparison:
