Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions ui/apps/platform/src/Components/PatternFly/BacklogListSelector.tsx
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}>
Copy link
Copy Markdown
Contributor

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:
Permission_sets

<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
Copy link
Copy Markdown
Contributor

@pedrottimark pedrottimark Oct 31, 2022

Choose a reason for hiding this comment

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

Asking to confirm my understanding or misunderstanding: Button element

  • in form: will have onClick callback to view collection in modal?
  • in modal: will have component={LinkShim} href={href} props to view collection in page?
    If yes, then are target="_blank" rel="noopener noreferrer" props also needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 CollectionAttacher.tsx#selectorListCells up to CollectionFormPage and pass through as props CollectionFormPage -> CollectionForm -> CollectionAttacher to accommodate the two use cases for the main page table and the modal table.

),
},
{
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)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  • pro: how likely to reuse in a context where application level is other than attach/detach
  • con: slight substitution by reader and possible effect on Find in Files

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 useSelectToggle() hook suffers from this mismatch as well.) I'm OK with changing the terminology or leaving as-is. Do you think people are more likely to Find in Files or Ctrl+Click to navigate around this code in the future?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 && (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First impression gave me a much needed smile: fetch more error, please ;)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 fetchingError?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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;
36 changes: 26 additions & 10 deletions ui/apps/platform/src/Containers/Collections/CollectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import ConfirmationModal from 'Components/PatternFly/ConfirmationModal';
import useSelectToggle from 'hooks/patternfly/useSelectToggle';
import useToasts from 'hooks/patternfly/useToasts';
import { collectionsBasePath } from 'routePaths';
import { deleteCollection } from 'services/CollectionsService';
import { CollectionResponse, deleteCollection } from 'services/CollectionsService';
import { CollectionPageAction } from './collections.utils';
import RuleSelector from './RuleSelector';
import CollectionAttacher from './CollectionAttacher';
Expand All @@ -52,6 +52,8 @@ export type CollectionFormProps = {
action: CollectionPageAction;
/* initial data used to populate the form */
initialData: Collection;
/* Collection object references for the list of ids in `initialData` */
initialEmbeddedCollections: CollectionResponse[];
/* Whether or not to display the collection results in an inline drawer. If false, will
display collection results in an overlay drawer. */
useInlineDrawer: boolean;
Expand Down Expand Up @@ -94,6 +96,7 @@ function CollectionForm({
hasWriteAccessForCollections,
action,
initialData,
initialEmbeddedCollections,
useInlineDrawer,
showBreadcrumbs,
}: CollectionFormProps) {
Expand All @@ -114,13 +117,13 @@ function CollectionForm({
const [isDeleting, setIsDeleting] = useState(false);
const { toasts, addToast, removeToast } = useToasts();

const { values, isValid, errors, handleChange, handleBlur, setFieldValue } = useFormik({
const { values, errors, handleChange, handleBlur, setFieldValue } = useFormik({
initialValues: initialData,
onSubmit: () => {},
validationSchema: yup.object({
name: yup.string().trim().required(),
description: yup.string(),
embeddedCollectionIds: yup.array(yup.string()),
embeddedCollections: yup.array(yup.string().trim().required()),
resourceSelectors: yup.object().shape({
Deployment: yupResourceSelectorObject(),
Namespace: yupResourceSelectorObject(),
Expand All @@ -129,9 +132,6 @@ function CollectionForm({
}),
});

// eslint-disable-next-line no-console
console.log('formik change', isValid, values, errors);

useEffect(() => {
toggleDrawer(useInlineDrawer);
}, [toggleDrawer, useInlineDrawer]);
Expand Down Expand Up @@ -181,6 +181,12 @@ function CollectionForm({
scopedResourceSelector: ScopedResourceSelector
) => setFieldValue(`resourceSelectors.${entityType}`, scopedResourceSelector);

const onEmbeddedCollectionsChange = (newCollections: CollectionResponse[]) =>
setFieldValue(
'embeddedCollections',
newCollections.map(({ id }) => id)
);

return (
<>
<Drawer isExpanded={drawerIsOpen} isInline={useInlineDrawer}>
Expand Down Expand Up @@ -375,10 +381,20 @@ function CollectionForm({
/>
</Flex>

<div className="pf-u-background-color-100 pf-u-p-lg">
<Title headingLevel="h2">Attach existing collections</Title>
<CollectionAttacher />
</div>
<Flex
className="pf-u-background-color-100 pf-u-p-lg"
direction={{ default: 'column' }}
spaceItems={{ default: 'spaceItemsMd' }}
>
<Title className="pf-u-mb-xs" headingLevel="h2">
Attach existing collections
</Title>
<p>Extend this collection by attaching other sets.</p>
<CollectionAttacher
initialEmbeddedCollections={initialEmbeddedCollections}
onSelectionChange={onEmbeddedCollectionsChange}
/>
</Flex>
</Flex>
{action.type !== 'view' && (
<div className="pf-u-background-color-100 pf-u-p-lg pf-u-py-md">
Expand Down
Loading