Skip to content

Commit b146983

Browse files
authored
fix(DualListSelector example): improved behaviour when filter is applied (#11097)
* fix(DualListSelectorTree example): improved behaviour with applied filter - "move all" button moves only those options that are visible due to the filter - change "number of options selected" text based on number of options shown due to the filter - when having selected options, then filtering them out, the "move selected" button will be disabled and won't move them * feat(DualListSelectorTree example): case insensitive filter * test(DualListSelectorTree): reenable integration tests + update demo app * test: fix notificationdrawergroups.spec.ts integration test
1 parent 96e94df commit b146983

File tree

4 files changed

+83
-74
lines changed

4 files changed

+83
-74
lines changed

packages/react-core/src/components/DualListSelector/examples/DualListSelectorTree.tsx

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
3636
const [chosenLeafIds, setChosenLeafIds] = React.useState<string[]>(['beans', 'beef', 'chicken', 'tofu']);
3737
const [chosenFilter, setChosenFilter] = React.useState<string>('');
3838
const [availableFilter, setAvailableFilter] = React.useState<string>('');
39-
let hiddenChosen: string[] = [];
40-
let hiddenAvailable: string[] = [];
4139

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

8482
// Builds a map of child leaf nodes by node id - memoized so that it only rebuilds the list if the data changes.
85-
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeText } = React.useMemo(() => {
83+
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeTexts } = React.useMemo(() => {
8684
let leavesById = {};
8785
let allLeaves: string[] = [];
8886
let nodeTexts = {};
@@ -94,32 +92,49 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
9492
return {
9593
memoizedLeavesById: leavesById,
9694
memoizedAllLeaves: allLeaves,
97-
memoizedNodeText: nodeTexts
95+
memoizedNodeTexts: nodeTexts
9896
};
9997
// eslint-disable-next-line react-hooks/exhaustive-deps
10098
}, [data]);
10199

100+
const matchesFilter = (value: string, filter: string) => value.toLowerCase().includes(filter.trim().toLowerCase());
101+
102+
const getVisibleLeafIds = (leafIds: string[], filter: string) => {
103+
const filterMatchingNodeIds = Object.keys(memoizedLeavesById).filter((nodeId) =>
104+
matchesFilter(memoizedNodeTexts[nodeId], filter)
105+
);
106+
const filterMatchingLeafIds = filterMatchingNodeIds.map((nodeId) => memoizedLeavesById[nodeId]).flat();
107+
return leafIds.filter((leafId) => filterMatchingLeafIds.includes(leafId));
108+
};
109+
110+
const availableLeafIds = memoizedAllLeaves.filter((leafId) => !chosenLeafIds.includes(leafId));
111+
const visibleChosenLeafIds = getVisibleLeafIds(chosenLeafIds, chosenFilter);
112+
const visibleAvailableLeafIds = getVisibleLeafIds(availableLeafIds, availableFilter);
113+
102114
const moveChecked = (toChosen: boolean) => {
115+
const visibleCheckedChosenLeafIds = checkedLeafIds.filter((leafId) => visibleChosenLeafIds.includes(leafId));
116+
const visibleCheckedAvailableLeafIds = checkedLeafIds.filter((leafId) => visibleAvailableLeafIds.includes(leafId));
117+
103118
setChosenLeafIds(
104119
(prevChosenIds) =>
105120
toChosen
106-
? [...prevChosenIds, ...checkedLeafIds] // add checked ids to chosen list
107-
: [...prevChosenIds.filter((x) => !checkedLeafIds.includes(x))] // remove checked ids from chosen list
121+
? [...prevChosenIds, ...visibleCheckedAvailableLeafIds] // add visible checked ids to chosen list
122+
: prevChosenIds.filter((x) => !visibleCheckedChosenLeafIds.includes(x)) // remove visible checked ids from chosen list
108123
);
109124

110125
// uncheck checked ids that just moved
111126
setCheckedLeafIds((prevChecked) =>
112127
toChosen
113-
? [...prevChecked.filter((x) => chosenLeafIds.includes(x))]
114-
: [...prevChecked.filter((x) => !chosenLeafIds.includes(x))]
128+
? prevChecked.filter((x) => !visibleCheckedAvailableLeafIds.includes(x))
129+
: prevChecked.filter((x) => !visibleCheckedChosenLeafIds.includes(x))
115130
);
116131
};
117132

118133
const moveAll = (toChosen: boolean) => {
119134
if (toChosen) {
120-
setChosenLeafIds(memoizedAllLeaves);
135+
setChosenLeafIds((prevChosenIds) => [...prevChosenIds, ...visibleAvailableLeafIds]);
121136
} else {
122-
setChosenLeafIds([]);
137+
setChosenLeafIds((prevChosenIds) => prevChosenIds.filter((id) => !visibleChosenLeafIds.includes(id)));
123138
}
124139
};
125140

@@ -149,15 +164,9 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
149164
isChosen: boolean
150165
) => {
151166
const nodeIdsToCheck = memoizedLeavesById[node.id].filter((id) =>
152-
isChosen
153-
? chosenLeafIds.includes(id) && !hiddenChosen.includes(id)
154-
: !chosenLeafIds.includes(id) && !hiddenAvailable.includes(id)
167+
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
155168
);
156-
if (isChosen) {
157-
hiddenChosen = [];
158-
} else {
159-
hiddenAvailable = [];
160-
}
169+
161170
setCheckedLeafIds((prevChecked) => {
162171
const otherCheckedNodeNames = prevChecked.filter((id) => !nodeIdsToCheck.includes(id));
163172
return !isChecked ? otherCheckedNodeNames : [...otherCheckedNodeNames, ...nodeIdsToCheck];
@@ -196,8 +205,8 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
196205
: descendentLeafIds.filter((id) => !chosenLeafIds.includes(id));
197206

198207
const hasMatchingChildren =
199-
filterValue && descendentsOnThisPane.some((id) => memoizedNodeText[id].includes(filterValue));
200-
const isFilterMatch = filterValue && node.text.includes(filterValue) && descendentsOnThisPane.length > 0;
208+
filterValue && descendentsOnThisPane.some((id) => matchesFilter(memoizedNodeTexts[id], filterValue));
209+
const isFilterMatch = filterValue && matchesFilter(node.text, filterValue) && descendentsOnThisPane.length > 0;
201210

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

211-
if (!isDisplayed) {
212-
if (isChosen) {
213-
hiddenChosen.push(node.id);
214-
} else {
215-
hiddenAvailable.push(node.id);
216-
}
217-
}
218-
219220
return [
220221
...(isDisplayed
221222
? [
@@ -242,9 +243,9 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
242243

243244
const buildPane = (isChosen: boolean): React.ReactNode => {
244245
const options: DualListSelectorTreeItemData[] = buildOptions(isChosen, data, false);
245-
const numOptions = isChosen ? chosenLeafIds.length : memoizedAllLeaves.length - chosenLeafIds.length;
246+
const numOptions = isChosen ? visibleChosenLeafIds.length : visibleAvailableLeafIds.length;
246247
const numSelected = checkedLeafIds.filter((id) =>
247-
isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)
248+
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
248249
).length;
249250
const status = `${numSelected} of ${numOptions} options selected`;
250251
const filterApplied = isChosen ? chosenFilter !== '' : availableFilter !== '';
@@ -285,7 +286,7 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
285286
{buildPane(false)}
286287
<DualListSelectorControlsWrapper>
287288
<DualListSelectorControl
288-
isDisabled={!checkedLeafIds.filter((x) => !chosenLeafIds.includes(x)).length}
289+
isDisabled={!checkedLeafIds.filter((x) => visibleAvailableLeafIds.includes(x)).length}
289290
onClick={() => moveChecked(true)}
290291
aria-label="Add selected"
291292
icon={<AngleRightIcon />}
@@ -304,7 +305,7 @@ export const DualListSelectorComposableTree: React.FunctionComponent<ExampleProp
304305
/>
305306
<DualListSelectorControl
306307
onClick={() => moveChecked(false)}
307-
isDisabled={!checkedLeafIds.filter((x) => !!chosenLeafIds.includes(x)).length}
308+
isDisabled={!checkedLeafIds.filter((x) => visibleChosenLeafIds.includes(x)).length}
308309
aria-label="Remove selected"
309310
icon={<AngleLeftIcon />}
310311
/>

packages/react-integration/cypress/integration/duallistselectortree.spec.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,18 @@ describe('Dual List Selector Tree Demo Test', () => {
3131
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 2);
3232
});
3333

34-
xit('Verify add all filtered options works', () => {
34+
it('Verify add all filtered options works', () => {
3535
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 2);
3636
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(0).type('Fru');
37-
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
37+
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 6);
3838
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(1).click();
39-
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 3);
39+
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(0).should('have.text', '0 of 0 options selected');
40+
cy.get('.pf-v6-c-empty-state').eq(0).should('exist');
41+
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
42+
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(1).should('have.text', '0 of 9 options selected');
4043
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(0).type('{backspace}{backspace}{backspace}');
4144
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
45+
cy.get('.pf-v6-c-dual-list-selector__status-text').eq(0).should('have.text', '0 of 2 options selected');
4246
});
4347

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

52-
xit('Verify remove all filtered options works', () => {
53-
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 0);
54-
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 1);
56+
it('Verify remove all filtered options works', () => {
57+
cy.get('.pf-v6-c-dual-list-selector__menu').eq(0).should('be.empty');
58+
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
5559
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(2).click();
5660
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 1);
57-
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 0);
61+
cy.get('.pf-v6-c-empty-state').eq(0).should('exist');
5862
cy.get('.pf-v6-c-dual-list-selector__tools-filter input').eq(1).type('{backspace}{backspace}{backspace}');
5963
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 3);
6064
cy.get('.pf-v6-c-dual-list-selector__controls-item').eq(2).click();
6165
cy.get('.pf-v6-c-dual-list-selector__list').eq(0).find('li').should('have.length', 4);
62-
cy.get('.pf-v6-c-dual-list-selector__list').eq(1).find('li').should('have.length', 0);
66+
cy.get('.pf-v6-c-dual-list-selector__menu').eq(1).should('be.empty');
6367
});
6468
});

packages/react-integration/cypress/integration/notificationdrawergroups.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ describe('Notification Drawer Groups Demo Test', () => {
7979
cy.wrap(toggleButton).type('{esc}', { waitForAnimations: true });
8080
cy.tick(200);
8181
cy.get('.notification-9.pf-v6-c-menu').should('not.exist');
82+
// restore the clock
83+
cy.clock().then((clock) => {
84+
clock.restore();
85+
});
8286
});
8387
});
8488

packages/react-integration/demo-app-ts/src/components/demos/DualListSelectorDemo/DualListSelectorTreeDemo.tsx

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
3636
const [chosenLeafIds, setChosenLeafIds] = React.useState<string[]>(['beans', 'beef', 'chicken', 'tofu']);
3737
const [chosenFilter, setChosenFilter] = React.useState<string>('');
3838
const [availableFilter, setAvailableFilter] = React.useState<string>('');
39-
let hiddenChosen: string[] = [];
40-
let hiddenAvailable: string[] = [];
4139

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

8482
// Builds a map of child leaf nodes by node id - memoized so that it only rebuilds the list if the data changes.
85-
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeText } = React.useMemo(() => {
83+
const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeTexts } = React.useMemo(() => {
8684
let leavesById: { [key: string]: string[] } = {};
8785
let allLeaves: string[] = [];
8886
let nodeTexts: { [key: string]: string } = {};
@@ -94,32 +92,49 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
9492
return {
9593
memoizedLeavesById: leavesById,
9694
memoizedAllLeaves: allLeaves,
97-
memoizedNodeText: nodeTexts
95+
memoizedNodeTexts: nodeTexts
9896
};
9997
// eslint-disable-next-line react-hooks/exhaustive-deps
10098
}, [data]);
10199

100+
const matchesFilter = (value: string, filter: string) => value.toLowerCase().includes(filter.trim().toLowerCase());
101+
102+
const getVisibleLeafIds = (leafIds: string[], filter: string) => {
103+
const filterMatchingNodeIds = Object.keys(memoizedLeavesById).filter((nodeId) =>
104+
matchesFilter(memoizedNodeTexts[nodeId], filter)
105+
);
106+
const filterMatchingLeafIds = filterMatchingNodeIds.map((nodeId) => memoizedLeavesById[nodeId]).flat();
107+
return leafIds.filter((leafId) => filterMatchingLeafIds.includes(leafId));
108+
};
109+
110+
const availableLeafIds = memoizedAllLeaves.filter((leafId) => !chosenLeafIds.includes(leafId));
111+
const visibleChosenLeafIds = getVisibleLeafIds(chosenLeafIds, chosenFilter);
112+
const visibleAvailableLeafIds = getVisibleLeafIds(availableLeafIds, availableFilter);
113+
102114
const moveChecked = (toChosen: boolean) => {
115+
const visibleCheckedChosenLeafIds = checkedLeafIds.filter((leafId) => visibleChosenLeafIds.includes(leafId));
116+
const visibleCheckedAvailableLeafIds = checkedLeafIds.filter((leafId) => visibleAvailableLeafIds.includes(leafId));
117+
103118
setChosenLeafIds(
104119
(prevChosenIds) =>
105120
toChosen
106-
? [...prevChosenIds, ...checkedLeafIds] // add checked ids to chosen list
107-
: [...prevChosenIds.filter((x) => !checkedLeafIds.includes(x))] // remove checked ids from chosen list
121+
? [...prevChosenIds, ...visibleCheckedAvailableLeafIds] // add visible checked ids to chosen list
122+
: prevChosenIds.filter((x) => !visibleCheckedChosenLeafIds.includes(x)) // remove visible checked ids from chosen list
108123
);
109124

110125
// uncheck checked ids that just moved
111126
setCheckedLeafIds((prevChecked) =>
112127
toChosen
113-
? [...prevChecked.filter((x) => chosenLeafIds.includes(x))]
114-
: [...prevChecked.filter((x) => !chosenLeafIds.includes(x))]
128+
? prevChecked.filter((x) => !visibleCheckedAvailableLeafIds.includes(x))
129+
: prevChecked.filter((x) => !visibleCheckedChosenLeafIds.includes(x))
115130
);
116131
};
117132

118133
const moveAll = (toChosen: boolean) => {
119134
if (toChosen) {
120-
setChosenLeafIds(memoizedAllLeaves);
135+
setChosenLeafIds((prevChosenIds) => [...prevChosenIds, ...visibleAvailableLeafIds]);
121136
} else {
122-
setChosenLeafIds([]);
137+
setChosenLeafIds((prevChosenIds) => prevChosenIds.filter((id) => !visibleChosenLeafIds.includes(id)));
123138
}
124139
};
125140

@@ -149,15 +164,9 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
149164
isChosen: boolean
150165
) => {
151166
const nodeIdsToCheck = memoizedLeavesById[node.id].filter((id) =>
152-
isChosen
153-
? chosenLeafIds.includes(id) && !hiddenChosen.includes(id)
154-
: !chosenLeafIds.includes(id) && !hiddenAvailable.includes(id)
167+
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
155168
);
156-
if (isChosen) {
157-
hiddenChosen = [];
158-
} else {
159-
hiddenAvailable = [];
160-
}
169+
161170
setCheckedLeafIds((prevChecked) => {
162171
const otherCheckedNodeNames = prevChecked.filter((id) => !nodeIdsToCheck.includes(id));
163172
return !isChecked ? otherCheckedNodeNames : [...otherCheckedNodeNames, ...nodeIdsToCheck];
@@ -189,16 +198,15 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
189198

190199
const isChecked = isNodeChecked(node, isChosen);
191200

192-
const filterValue = (isChosen ? chosenFilter : availableFilter).toLowerCase().trim();
201+
const filterValue = isChosen ? chosenFilter : availableFilter;
193202
const descendentLeafIds = memoizedLeavesById[node.id];
194203
const descendentsOnThisPane = isChosen
195204
? descendentLeafIds.filter((id) => chosenLeafIds.includes(id))
196205
: descendentLeafIds.filter((id) => !chosenLeafIds.includes(id));
197206

198207
const hasMatchingChildren =
199-
filterValue && descendentsOnThisPane.some((id) => memoizedNodeText[id].toLowerCase().includes(filterValue));
200-
const isFilterMatch =
201-
filterValue && node.text.toLowerCase().includes(filterValue) && descendentsOnThisPane.length > 0;
208+
filterValue && descendentsOnThisPane.some((id) => matchesFilter(memoizedNodeTexts[id], filterValue));
209+
const isFilterMatch = filterValue && matchesFilter(node.text, filterValue) && descendentsOnThisPane.length > 0;
202210

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

212-
if (!isDisplayed) {
213-
if (isChosen) {
214-
hiddenChosen.push(node.id);
215-
} else {
216-
hiddenAvailable.push(node.id);
217-
}
218-
}
219-
220220
return [
221221
...(isDisplayed
222222
? [
@@ -243,9 +243,9 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
243243

244244
const buildPane = (isChosen: boolean): React.ReactNode => {
245245
const options: DualListSelectorTreeItemData[] = buildOptions(isChosen, data, false);
246-
const numOptions = isChosen ? chosenLeafIds.length : memoizedAllLeaves.length - chosenLeafIds.length;
246+
const numOptions = isChosen ? visibleChosenLeafIds.length : visibleAvailableLeafIds.length;
247247
const numSelected = checkedLeafIds.filter((id) =>
248-
isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)
248+
isChosen ? visibleChosenLeafIds.includes(id) : visibleAvailableLeafIds.includes(id)
249249
).length;
250250
const status = `${numSelected} of ${numOptions} options selected`;
251251
const filterApplied = isChosen ? chosenFilter !== '' : availableFilter !== '';
@@ -286,7 +286,7 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
286286
{buildPane(false)}
287287
<DualListSelectorControlsWrapper>
288288
<DualListSelectorControl
289-
isDisabled={!checkedLeafIds.filter((x) => !chosenLeafIds.includes(x)).length}
289+
isDisabled={!checkedLeafIds.filter((x) => visibleAvailableLeafIds.includes(x)).length}
290290
onClick={() => moveChecked(true)}
291291
aria-label="Add selected"
292292
icon={<AngleRightIcon />}
@@ -305,7 +305,7 @@ const DualListSelectorComposableTree: React.FunctionComponent<ExampleProps> = ({
305305
/>
306306
<DualListSelectorControl
307307
onClick={() => moveChecked(false)}
308-
isDisabled={!checkedLeafIds.filter((x) => !!chosenLeafIds.includes(x)).length}
308+
isDisabled={!checkedLeafIds.filter((x) => visibleChosenLeafIds.includes(x)).length}
309309
aria-label="Remove selected"
310310
icon={<AngleLeftIcon />}
311311
/>

0 commit comments

Comments
 (0)