Skip to content

Commit 56724a5

Browse files
authored
Merge pull request #383 from eccenca/fix/multiSelectInDevelop-CMEM-7479
Fix MultiSelect component to not set newlySelected incorrectly (CMEM-7479)
2 parents da7e347 + c26e099 commit 56724a5

File tree

5 files changed

+172
-15
lines changed

5 files changed

+172
-15
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
3333
- new icons:
3434
- `state-confirmed-all`
3535
- `state-declined-all`
36+
- `<MultiSuggestField />`
37+
- `MultiSuggestFieldSelectionProps` provides `newlyRemoved` for callbacks set when removing a selected item
3638

3739
### Fixed
3840

@@ -57,6 +59,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
5759
- use the latest provided `onChange` function
5860
- `<TextField />`, `<TextArea />`
5961
- fix emoji false-positives in invisible character detection
62+
- `<MultiSuggestField />`
63+
- `onSelection` now sets `newlySelected` only for add actions and no longer sets it to the last element
64+
- border of the BlueprintJS `Tag` elements were fixed
6065
- Focus outlines
6166
- we use again bold focus outlines for input elements
6267
- they are also used for clickable elements that are focused via keyboard (tab navigation)

src/components/MultiSelect/MultiSelect.tsx

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import {
1717
IconButton,
1818
MenuItem,
1919
OverflowText,
20-
Spinner
20+
Spinner,
2121
} from "./../../index";
2222

2323
export interface MultiSuggestFieldSelectionProps<T> {
2424
newlySelected?: T;
25+
newlyRemoved?: T;
2526
selectedItems: T[];
2627
createdItems: Partial<T>[];
2728
}
@@ -178,6 +179,8 @@ export function MultiSuggestField<T>({
178179
intent,
179180
...otherMultiSelectProps
180181
}: MultiSuggestFieldProps<T>) {
182+
type SelectionChange = { type: "selected"; item: T } | { type: "removed"; item: T } | { type: "none" };
183+
181184
// Options created by a user
182185
const createdItems = useRef<T[]>([]);
183186
// Options passed ouside (f.e. from the backend)
@@ -199,6 +202,7 @@ export function MultiSuggestField<T>({
199202
query?: string;
200203
timeoutId?: number;
201204
}>({});
205+
const selectionChange = useRef<SelectionChange>({ type: "none" });
202206

203207
/** Update external items when they change
204208
* e.g for auto-complete when query change
@@ -209,11 +213,21 @@ export function MultiSuggestField<T>({
209213
}, [items.map((item) => itemId(item)).join("|")]);
210214

211215
React.useEffect(() => {
212-
onSelection?.({
213-
newlySelected: selectedItems.slice(-1)[0],
216+
const selectionParams: MultiSuggestFieldSelectionProps<T> = {
214217
createdItems: createdItems.current,
215218
selectedItems,
216-
});
219+
};
220+
221+
if (selectionChange.current.type === "selected") {
222+
selectionParams.newlySelected = selectionChange.current.item;
223+
}
224+
225+
if (selectionChange.current.type === "removed") {
226+
selectionParams.newlyRemoved = selectionChange.current.item;
227+
}
228+
229+
onSelection?.(selectionParams);
230+
selectionChange.current = { type: "none" };
217231
}, [
218232
onSelection,
219233
selectedItems.map((item) => itemId(item)).join("|"),
@@ -228,6 +242,7 @@ export function MultiSuggestField<T>({
228242
return;
229243
}
230244

245+
selectionChange.current = { type: "none" };
231246
setSelectedItems(externalSelectedItems);
232247
}, [externalSelectedItems?.map((item) => itemId(item)).join("|")]);
233248

@@ -268,13 +283,18 @@ export function MultiSuggestField<T>({
268283
* @param matcher
269284
*/
270285
const removeItemSelection = (matcher: string) => {
271-
const filteredItems = selectedItems.filter((item) => itemId(item) !== matcher);
272-
setSelectedItems(filteredItems);
286+
setSelectedItems((items) => {
287+
const removedItem = items.find((item) => itemId(item) === matcher);
288+
289+
selectionChange.current = removedItem ? { type: "removed", item: removedItem } : { type: "none" };
290+
291+
return items.filter((item) => itemId(item) !== matcher);
292+
});
273293
};
274294

275295
const defaultFilterPredicate = (item: T, query: string) => {
276-
const searchWords = highlighterUtils.extractSearchWords(query, true)
277-
return highlighterUtils.matchesAllWords(itemLabel(item).toLowerCase(), searchWords)
296+
const searchWords = highlighterUtils.extractSearchWords(query, true);
297+
return highlighterUtils.matchesAllWords(itemLabel(item).toLowerCase(), searchWords);
278298
};
279299

280300
/**
@@ -286,6 +306,7 @@ export function MultiSuggestField<T>({
286306
if (itemHasBeenSelectedAlready(itemId(item))) {
287307
removeItemSelection(itemId(item));
288308
} else {
309+
selectionChange.current = { type: "selected", item };
289310
setSelectedItems((items) => [...items, item]);
290311
}
291312

@@ -365,6 +386,7 @@ export function MultiSuggestField<T>({
365386
const handleClear = () => {
366387
requestState.current.query = "";
367388

389+
selectionChange.current = { type: "none" };
368390
setSelectedItems([]);
369391
setFilteredItems([...externalItems, ...createdItems.current]);
370392
};
@@ -375,7 +397,13 @@ export function MultiSuggestField<T>({
375397
* @param index
376398
*/
377399
const removeTagFromSelectionViaIndex = (_label: React.ReactNode, index: number) => {
378-
setSelectedItems([...selectedItems.slice(0, index), ...selectedItems.slice(index + 1)]);
400+
setSelectedItems((items) => {
401+
const removedItem = items[index];
402+
403+
selectionChange.current = removedItem ? { type: "removed", item: removedItem } : { type: "none" };
404+
405+
return [...items.slice(0, index), ...items.slice(index + 1)];
406+
});
379407
};
380408

381409
/**

src/components/MultiSuggestField/MultiSuggestField.stories.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { Meta, StoryFn } from "@storybook/react";
55
import { fn } from "@storybook/test";
66

77
import { helpersArgTypes } from "../../../.storybook/helpers";
8+
import { Notification } from "../Notification/Notification";
9+
import Spacing from "../Separation/Spacing";
810

911
import { MultiSuggestField, MultiSuggestFieldSelectionProps, SimpleDialog } from "./../../../index";
1012

@@ -61,7 +63,7 @@ Default.args = {
6163

6264
/**
6365
* Display always the dropdown after the element was clicked on.
64-
* Do not wait until the query input was startet.
66+
* Do not wait until the query input was started.
6567
*/
6668
export const dropdownOnFocus = Template.bind({});
6769
dropdownOnFocus.args = {
@@ -259,3 +261,40 @@ CustomSearch.args = {
259261
return item.testId.toLowerCase().includes(query) || item.testLabel.toLowerCase().includes(query);
260262
},
261263
};
264+
265+
const SelectionNotificationComponent = (): React.JSX.Element => {
266+
const [notification, setNotification] = useState<string | null>(null);
267+
268+
const availableItems = useMemo<string[]>(() => ["existing item"], []);
269+
270+
const identity = useCallback((item: string): string => item, []);
271+
272+
const handleOnSelect = useCallback((params: MultiSuggestFieldSelectionProps<string>) => {
273+
if (params.newlySelected) {
274+
setNotification(`Element added: ${params.newlySelected}`);
275+
} else if (params.newlyRemoved) {
276+
setNotification(`Element removed: ${params.newlyRemoved}`);
277+
}
278+
}, []);
279+
280+
return (
281+
<OverlaysProvider>
282+
{notification && <Notification intent={"info"}>{notification}</Notification>}
283+
<Spacing size={"medium"} />
284+
<MultiSuggestField<string>
285+
items={availableItems}
286+
prePopulateWithItems={true}
287+
onSelection={handleOnSelect}
288+
itemId={identity}
289+
itemLabel={identity}
290+
createNewItemFromQuery={identity}
291+
/>
292+
</OverlaysProvider>
293+
);
294+
};
295+
296+
/**
297+
* Demonstrates the `newlySelected` and `newlyRemoved` properties of the `onSelection` callback.
298+
* A notification appears when an element is added or removed from the selection.
299+
*/
300+
export const selectionNotification = SelectionNotificationComponent.bind({});

src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,86 @@ describe("MultiSuggestField", () => {
261261
});
262262
});
263263

264+
it("should set newlySelected only when an item is added", async () => {
265+
const onSelection = jest.fn();
266+
const initiallySelected = predefinedNotControlledValues.args.selectedItems;
267+
268+
const { container } = render(
269+
<MultiSuggestField
270+
{...dropdownOnFocus.args}
271+
items={items}
272+
selectedItems={initiallySelected}
273+
onSelection={onSelection}
274+
/>
275+
);
276+
277+
await waitFor(() => {
278+
expect(onSelection).toHaveBeenCalledWith({
279+
createdItems: [],
280+
selectedItems: initiallySelected,
281+
});
282+
});
283+
284+
onSelection.mockClear();
285+
286+
const [inputContainer] = container.getElementsByClassName("eccgui-multiselect");
287+
const [input] = inputContainer.getElementsByTagName("input");
288+
289+
fireEvent.click(input);
290+
291+
await waitFor(() => {
292+
const listbox = screen.getByRole("listbox");
293+
const menuItems = listbox.getElementsByClassName("eccgui-menu__item");
294+
295+
expect(menuItems.length).toBe(dropdownOnFocus.args.items.length);
296+
297+
fireEvent.click(menuItems[2]);
298+
});
299+
300+
await waitFor(() => {
301+
expect(onSelection).toHaveBeenLastCalledWith({
302+
createdItems: [],
303+
newlySelected: items[2],
304+
selectedItems: [...initiallySelected, items[2]],
305+
});
306+
});
307+
});
308+
309+
it("should set newlyRemoved only when an item is removed", async () => {
310+
const onSelection = jest.fn();
311+
const initiallySelected = predefinedNotControlledValues.args.selectedItems;
312+
313+
const { container } = render(
314+
<MultiSuggestField
315+
{...predefinedNotControlledValues.args}
316+
selectedItems={initiallySelected}
317+
onSelection={onSelection}
318+
/>
319+
);
320+
321+
await waitFor(() => {
322+
expect(onSelection).toHaveBeenCalledWith({
323+
createdItems: [],
324+
selectedItems: initiallySelected,
325+
});
326+
});
327+
328+
onSelection.mockClear();
329+
330+
const [firstTag] = Array.from(container.querySelectorAll("span[data-tag-index]"));
331+
const removeTagButton = firstTag.querySelector("button");
332+
333+
fireEvent.click(removeTagButton!);
334+
335+
await waitFor(() => {
336+
expect(onSelection).toHaveBeenLastCalledWith({
337+
createdItems: [],
338+
newlyRemoved: initiallySelected[0],
339+
selectedItems: initiallySelected.slice(1),
340+
});
341+
});
342+
});
343+
264344
it("should filter items by custom search function", async () => {
265345
const { container } = render(<MultiSuggestField {...CustomSearch.args} items={items} />);
266346

@@ -340,6 +420,13 @@ describe("MultiSuggestField", () => {
340420
expect(getByText(firstSelected)).toBeInTheDocument();
341421
expect(getByText(secondSelected)).toBeInTheDocument();
342422
});
423+
424+
await waitFor(() => {
425+
expect(onSelection).toHaveBeenCalledWith({
426+
createdItems: [],
427+
selectedItems: predefinedNotControlledValues.args.selectedItems,
428+
});
429+
});
343430
});
344431

345432
it("should call onSelection function with the selected items", async () => {
@@ -485,7 +572,6 @@ describe("MultiSuggestField", () => {
485572
await waitFor(() => {
486573
const expectedObject = {
487574
createdItems: [],
488-
newlySelected: items.at(-1),
489575
selectedItems: items,
490576
};
491577
expect(onSelection).toHaveBeenCalledWith(expectedObject);
@@ -513,7 +599,6 @@ describe("MultiSuggestField", () => {
513599
await waitFor(() => {
514600
const expectedObject = {
515601
createdItems: [],
516-
newlySelected: items.at(-1),
517602
selectedItems: items,
518603
};
519604
expect(onSelection).toHaveBeenCalledWith(expectedObject);
@@ -536,7 +621,7 @@ describe("MultiSuggestField", () => {
536621

537622
const expectedObject = {
538623
createdItems: [],
539-
newlySelected: selected.at(-1),
624+
newlyRemoved: items[i],
540625
selectedItems: selected,
541626
};
542627

src/components/Tag/tag.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ $tag-round-adjustment: 0 !default;
3030
@import "~@blueprintjs/core/src/components/tag/tag";
3131

3232
.#{$eccgui}-tag__item {
33-
--eccgui-tag-border-width: 1px;
34-
3533
flex-grow: 0;
3634
flex-shrink: 0;
3735
min-width: calc(#{$tag-height} - 2px);
@@ -141,6 +139,8 @@ $tag-round-adjustment: 0 !default;
141139
}
142140

143141
.#{$ns}-tag {
142+
--eccgui-tag-border-width: 1px;
143+
144144
border-style: solid;
145145
border-width: var(--eccgui-tag-border-width);
146146

0 commit comments

Comments
 (0)