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

fix(DualListSelector example): improved behaviour when filter is applied #11097

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
const [chosenLeafIds, setChosenLeafIds] = React.useState<string[]>(['beans', 'beef', 'chicken', 'tofu']);
const [chosenFilter, setChosenFilter] = React.useState<string>('');
const [availableFilter, setAvailableFilter] = React.useState<string>('');
let hiddenChosen: string[] = [];
let hiddenAvailable: string[] = [];

// helper function to build memoized lists
const buildTextById = (node: FoodNode): { [key: string]: string } => {
Expand Down Expand Up @@ -82,7 +80,7 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
};

// Builds a map of child leaf nodes by node id - memoized so that it only rebuilds the list if the data changes.
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeText } = React.useMemo(() => {
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeTexts } = React.useMemo(() => {
let leavesById = {};
let allLeaves: string[] = [];
let nodeTexts = {};
Expand All @@ -94,32 +92,49 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
return {
memoizedLeavesById: leavesById,
memoizedAllLeaves: allLeaves,
memoizedNodeText: nodeTexts
memoizedNodeTexts: nodeTexts
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);

const matchesFilter = (value: string, filter: string) => value.toLowerCase().includes(filter.trim().toLowerCase());

const getVisibleLeafIds = (leafIds: string[], filter: string) => {
const filterMatchingNodeIds = Object.keys(memoizedLeavesById).filter((nodeId) =>
matchesFilter(memoizedNodeTexts[nodeId], filter)
);
const filterMatchingLeafIds = filterMatchingNodeIds.map((nodeId) => memoizedLeavesById[nodeId]).flat();
return leafIds.filter((leafId) => filterMatchingLeafIds.includes(leafId));
};

const availableLeafIds = memoizedAllLeaves.filter((leafId) => !chosenLeafIds.includes(leafId));
const visibleChosenLeafIds = getVisibleLeafIds(chosenLeafIds, chosenFilter);
const visibleAvailableLeafIds = getVisibleLeafIds(availableLeafIds, availableFilter);

const moveChecked = (toChosen: boolean) => {
const visibleCheckedChosenLeafIds = checkedLeafIds.filter((leafId) => visibleChosenLeafIds.includes(leafId));
const visibleCheckedAvailableLeafIds = checkedLeafIds.filter((leafId) => visibleAvailableLeafIds.includes(leafId));

setChosenLeafIds(
(prevChosenIds) =>
toChosen
? [...prevChosenIds, ...checkedLeafIds] // add checked ids to chosen list
: [...prevChosenIds.filter((x) => !checkedLeafIds.includes(x))] // remove checked ids from chosen list
? [...prevChosenIds, ...visibleCheckedAvailableLeafIds] // add visible checked ids to chosen list
: prevChosenIds.filter((x) => !visibleCheckedChosenLeafIds.includes(x)) // remove visible checked ids from chosen list
);

// uncheck checked ids that just moved
setCheckedLeafIds((prevChecked) =>
toChosen
? [...prevChecked.filter((x) => chosenLeafIds.includes(x))]
: [...prevChecked.filter((x) => !chosenLeafIds.includes(x))]
? prevChecked.filter((x) => !visibleCheckedAvailableLeafIds.includes(x))
: prevChecked.filter((x) => !visibleCheckedChosenLeafIds.includes(x))
);
};

const moveAll = (toChosen: boolean) => {
if (toChosen) {
setChosenLeafIds(memoizedAllLeaves);
setChosenLeafIds((prevChosenIds) => [...prevChosenIds, ...visibleAvailableLeafIds]);
} else {
setChosenLeafIds([]);
setChosenLeafIds((prevChosenIds) => prevChosenIds.filter((id) => !visibleChosenLeafIds.includes(id)));
}
};

Expand Down Expand Up @@ -149,15 +164,9 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
isChosen: boolean
) => {
const nodeIdsToCheck = memoizedLeavesById[node.id].filter((id) =>
isChosen
? chosenLeafIds.includes(id) && !hiddenChosen.includes(id)
: !chosenLeafIds.includes(id) && !hiddenAvailable.includes(id)
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
);
if (isChosen) {
hiddenChosen = [];
} else {
hiddenAvailable = [];
}

setCheckedLeafIds((prevChecked) => {
const otherCheckedNodeNames = prevChecked.filter((id) => !nodeIdsToCheck.includes(id));
return !isChecked ? otherCheckedNodeNames : [...otherCheckedNodeNames, ...nodeIdsToCheck];
Expand Down Expand Up @@ -196,8 +205,8 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
: descendentLeafIds.filter((id) => !chosenLeafIds.includes(id));

const hasMatchingChildren =
filterValue && descendentsOnThisPane.some((id) => memoizedNodeText[id].includes(filterValue));
const isFilterMatch = filterValue && node.text.includes(filterValue) && descendentsOnThisPane.length > 0;
filterValue && descendentsOnThisPane.some((id) => matchesFilter(memoizedNodeTexts[id], filterValue));
const isFilterMatch = filterValue && matchesFilter(node.text, filterValue) && descendentsOnThisPane.length > 0;

// A node is displayed if either of the following is true:
// - There is no filter value and this node or its descendents belong on this pane
Expand All @@ -208,14 +217,6 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
(hasParentMatch && descendentsOnThisPane.length > 0) ||
isFilterMatch;

if (!isDisplayed) {
if (isChosen) {
hiddenChosen.push(node.id);
} else {
hiddenAvailable.push(node.id);
}
}

return [
...(isDisplayed
? [
Expand All @@ -242,9 +243,9 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp

const buildPane = (isChosen: boolean): React.ReactNode => {
const options: DualListSelectorTreeItemData[] = buildOptions(isChosen, data, false);
const numOptions = isChosen ? chosenLeafIds.length : memoizedAllLeaves.length - chosenLeafIds.length;
const numOptions = isChosen ? visibleChosenLeafIds.length : visibleAvailableLeafIds.length;
const numSelected = checkedLeafIds.filter((id) =>
isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
).length;
const status = `${numSelected} of ${numOptions} options selected`;
const filterApplied = isChosen ? chosenFilter !== '' : availableFilter !== '';
Expand Down Expand Up @@ -285,7 +286,7 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
{buildPane(false)}
<DualListSelectorControlsWrapper>
<DualListSelectorControl
isDisabled={!checkedLeafIds.filter((x) => !chosenLeafIds.includes(x)).length}
isDisabled={!checkedLeafIds.filter((x) => visibleAvailableLeafIds.includes(x)).length}
onClick={() => moveChecked(true)}
aria-label="Add selected"
icon={<AngleRightIcon />}
Expand All @@ -304,7 +305,7 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
/>
<DualListSelectorControl
onClick={() => moveChecked(false)}
isDisabled={!checkedLeafIds.filter((x) => !!chosenLeafIds.includes(x)).length}
isDisabled={!checkedLeafIds.filter((x) => visibleChosenLeafIds.includes(x)).length}
aria-label="Remove selected"
icon={<AngleLeftIcon />}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@ describe('Dual List Selector Tree Demo Test', () => {
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 2);
});

xit('Verify add all filtered options works', () => {
it('Verify add all filtered options works', () => {
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 2);
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(0).type('Fru');
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 6);
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(1).click();
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 3);
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(0).should('have.text', '0 of 0 options selected');
cy.get('.pf-v6-c-empty-state').eq(0).should('exist');
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 3); // "Chosen" list is at index 0, because "Available" displays empty state instead
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(1).should('have.text', '0 of 9 options selected');
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(0).type('{backspace}{backspace}{backspace}');
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(0).should('have.text', '0 of 2 options selected');
});

it('Verify chosen search works', () => {
Expand All @@ -49,16 +53,16 @@ describe('Dual List Selector Tree Demo Test', () => {
cy.get('.pf-v6-c-dual-list-selector__menu').eq(1).find('li').should('have.length', 1);
});

xit('Verify remove all filtered options works', () => {
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 0);
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 1);
it('Verify remove all filtered options works', () => {
cy.get('.pf-v6-c-dual-list-selector__menu').eq(0).should('be.empty');
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1); // "Chosen" list is at index 0, because "Available" is empty
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(2).click();
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 0);
cy.get('.pf-v6-c-empty-state').eq(0).should('exist');
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(1).type('{backspace}{backspace}{backspace}');
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 3);
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(2).click();
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 4);
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 0);
cy.get('.pf-v6-c-dual-list-selector__menu').eq(1).should('be.empty');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ describe('Notification Drawer Groups Demo Test', () => {
cy.wrap(toggleButton).type('{esc}', { waitForAnimations: true });
cy.tick(200);
cy.get('.notification-9.pf-v6-c-menu').should('not.exist');
// restore the clock
cy.clock().then((clock) => {
clock.restore();
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
const [chosenLeafIds, setChosenLeafIds] = React.useState<string[]>(['beans', 'beef', 'chicken', 'tofu']);
const [chosenFilter, setChosenFilter] = React.useState<string>('');
const [availableFilter, setAvailableFilter] = React.useState<string>('');
let hiddenChosen: string[] = [];
let hiddenAvailable: string[] = [];

// helper function to build memoized lists
const buildTextById = (node: FoodNode): { [key: string]: string } => {
Expand Down Expand Up @@ -82,7 +80,7 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
};

// Builds a map of child leaf nodes by node id - memoized so that it only rebuilds the list if the data changes.
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeText } = React.useMemo(() => {
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeTexts } = React.useMemo(() => {
let leavesById: { [key: string]: string[] } = {};
let allLeaves: string[] = [];
let nodeTexts: { [key: string]: string } = {};
Expand All @@ -94,32 +92,49 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
return {
memoizedLeavesById: leavesById,
memoizedAllLeaves: allLeaves,
memoizedNodeText: nodeTexts
memoizedNodeTexts: nodeTexts
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);

const matchesFilter = (value: string, filter: string) => value.toLowerCase().includes(filter.trim().toLowerCase());

const getVisibleLeafIds = (leafIds: string[], filter: string) => {
const filterMatchingNodeIds = Object.keys(memoizedLeavesById).filter((nodeId) =>
matchesFilter(memoizedNodeTexts[nodeId], filter)
);
const filterMatchingLeafIds = filterMatchingNodeIds.map((nodeId) => memoizedLeavesById[nodeId]).flat();
return leafIds.filter((leafId) => filterMatchingLeafIds.includes(leafId));
};

const availableLeafIds = memoizedAllLeaves.filter((leafId) => !chosenLeafIds.includes(leafId));
const visibleChosenLeafIds = getVisibleLeafIds(chosenLeafIds, chosenFilter);
const visibleAvailableLeafIds = getVisibleLeafIds(availableLeafIds, availableFilter);

const moveChecked = (toChosen: boolean) => {
const visibleCheckedChosenLeafIds = checkedLeafIds.filter((leafId) => visibleChosenLeafIds.includes(leafId));
const visibleCheckedAvailableLeafIds = checkedLeafIds.filter((leafId) => visibleAvailableLeafIds.includes(leafId));

setChosenLeafIds(
(prevChosenIds) =>
toChosen
? [...prevChosenIds, ...checkedLeafIds] // add checked ids to chosen list
: [...prevChosenIds.filter((x) => !checkedLeafIds.includes(x))] // remove checked ids from chosen list
? [...prevChosenIds, ...visibleCheckedAvailableLeafIds] // add visible checked ids to chosen list
: prevChosenIds.filter((x) => !visibleCheckedChosenLeafIds.includes(x)) // remove visible checked ids from chosen list
);

// uncheck checked ids that just moved
setCheckedLeafIds((prevChecked) =>
toChosen
? [...prevChecked.filter((x) => chosenLeafIds.includes(x))]
: [...prevChecked.filter((x) => !chosenLeafIds.includes(x))]
? prevChecked.filter((x) => !visibleCheckedAvailableLeafIds.includes(x))
: prevChecked.filter((x) => !visibleCheckedChosenLeafIds.includes(x))
);
};

const moveAll = (toChosen: boolean) => {
if (toChosen) {
setChosenLeafIds(memoizedAllLeaves);
setChosenLeafIds((prevChosenIds) => [...prevChosenIds, ...visibleAvailableLeafIds]);
} else {
setChosenLeafIds([]);
setChosenLeafIds((prevChosenIds) => prevChosenIds.filter((id) => !visibleChosenLeafIds.includes(id)));
}
};

Expand Down Expand Up @@ -149,15 +164,9 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
isChosen: boolean
) => {
const nodeIdsToCheck = memoizedLeavesById[node.id].filter((id) =>
isChosen
? chosenLeafIds.includes(id) && !hiddenChosen.includes(id)
: !chosenLeafIds.includes(id) && !hiddenAvailable.includes(id)
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
);
if (isChosen) {
hiddenChosen = [];
} else {
hiddenAvailable = [];
}

setCheckedLeafIds((prevChecked) => {
const otherCheckedNodeNames = prevChecked.filter((id) => !nodeIdsToCheck.includes(id));
return !isChecked ? otherCheckedNodeNames : [...otherCheckedNodeNames, ...nodeIdsToCheck];
Expand Down Expand Up @@ -189,16 +198,15 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({

const isChecked = isNodeChecked(node, isChosen);

const filterValue = (isChosen ? chosenFilter : availableFilter).toLowerCase().trim();
const filterValue = isChosen ? chosenFilter : availableFilter;
const descendentLeafIds = memoizedLeavesById[node.id];
const descendentsOnThisPane = isChosen
? descendentLeafIds.filter((id) => chosenLeafIds.includes(id))
: descendentLeafIds.filter((id) => !chosenLeafIds.includes(id));

const hasMatchingChildren =
filterValue && descendentsOnThisPane.some((id) => memoizedNodeText[id].toLowerCase().includes(filterValue));
const isFilterMatch =
filterValue && node.text.toLowerCase().includes(filterValue) && descendentsOnThisPane.length > 0;
filterValue && descendentsOnThisPane.some((id) => matchesFilter(memoizedNodeTexts[id], filterValue));
const isFilterMatch = filterValue && matchesFilter(node.text, filterValue) && descendentsOnThisPane.length > 0;

// A node is displayed if either of the following is true:
// - There is no filter value and this node or its descendents belong on this pane
Expand All @@ -209,14 +217,6 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
(hasParentMatch && descendentsOnThisPane.length > 0) ||
isFilterMatch;

if (!isDisplayed) {
if (isChosen) {
hiddenChosen.push(node.id);
} else {
hiddenAvailable.push(node.id);
}
}

return [
...(isDisplayed
? [
Expand All @@ -243,9 +243,9 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({

const buildPane = (isChosen: boolean): React.ReactNode => {
const options: DualListSelectorTreeItemData[] = buildOptions(isChosen, data, false);
const numOptions = isChosen ? chosenLeafIds.length : memoizedAllLeaves.length - chosenLeafIds.length;
const numOptions = isChosen ? visibleChosenLeafIds.length : visibleAvailableLeafIds.length;
const numSelected = checkedLeafIds.filter((id) =>
isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
).length;
const status = `${numSelected} of ${numOptions} options selected`;
const filterApplied = isChosen ? chosenFilter !== '' : availableFilter !== '';
Expand Down Expand Up @@ -286,7 +286,7 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
{buildPane(false)}
<DualListSelectorControlsWrapper>
<DualListSelectorControl
isDisabled={!checkedLeafIds.filter((x) => !chosenLeafIds.includes(x)).length}
isDisabled={!checkedLeafIds.filter((x) => visibleAvailableLeafIds.includes(x)).length}
onClick={() => moveChecked(true)}
aria-label="Add selected"
icon={<AngleRightIcon />}
Expand All @@ -305,7 +305,7 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
/>
<DualListSelectorControl
onClick={() => moveChecked(false)}
isDisabled={!checkedLeafIds.filter((x) => !!chosenLeafIds.includes(x)).length}
isDisabled={!checkedLeafIds.filter((x) => visibleChosenLeafIds.includes(x)).length}
aria-label="Remove selected"
icon={<AngleLeftIcon />}
/>
Expand Down
Loading