@linzjs/step-ag-grid 7.0.4 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@linzjs/step-ag-grid",
3
3
  "repository": "github:linz/step-ag-grid.git",
4
4
  "license": "MIT",
5
- "version": "7.0.4",
5
+ "version": "7.1.1",
6
6
  "keywords": [
7
7
  "aggrid",
8
8
  "ag-grid",
@@ -247,7 +247,7 @@ export const GridFormDropDown = <RowType extends GridBaseRow>(props: GridFormPop
247
247
  data-testid={"filteredMenu-free-text-input"}
248
248
  defaultValue={filter}
249
249
  data-disableenterautosave={true}
250
- data-allowtabtoSave={true}
250
+ data-allowtabtosave={true}
251
251
  onChange={(e) => setFilter(e.target.value)}
252
252
  onKeyDown={handleKeyDown}
253
253
  onKeyUp={handleKeyUp}
@@ -14,7 +14,7 @@ import {
14
14
  } from "react";
15
15
  import { GridBaseRow } from "../Grid";
16
16
  import { ComponentLoadingWrapper } from "../ComponentLoadingWrapper";
17
- import { groupBy, isEmpty, pick, toPairs } from "lodash-es";
17
+ import { fromPairs, groupBy, isEmpty, pick, toPairs } from "lodash-es";
18
18
  import { LuiCheckboxInput } from "@linzjs/lui";
19
19
  import { useGridPopoverHook } from "../GridPopoverHook";
20
20
  import { MenuSeparatorString } from "./GridFormDropDown";
@@ -25,6 +25,8 @@ import { useGridPopoverContext } from "../../contexts/GridPopoverContext";
25
25
  import { FormError } from "../../lui/FormError";
26
26
  import { textMatch } from "../../utils/textMatcher";
27
27
 
28
+ type HeaderGroupType = Record<string, MultiSelectOption[]> | undefined;
29
+
28
30
  export interface MultiSelectOption {
29
31
  value: any;
30
32
  label?: string;
@@ -112,15 +114,34 @@ export const GridFormMultiSelect = <RowType extends GridBaseRow>(props: GridForm
112
114
  /**
113
115
  * Groups options into their header groups
114
116
  */
115
- const headerGroups = useMemo(
116
- () =>
117
- options &&
118
- groupBy(
119
- options.filter((o) => textMatch(o.label, filter) && o.value),
120
- "filter",
121
- ),
122
- [filter, options],
123
- );
117
+ const headerGroups = useMemo(() => {
118
+ if (!options) return undefined;
119
+ const result = groupBy(
120
+ options.filter((o) => textMatch(o.label, filter) && o.value),
121
+ "filter",
122
+ );
123
+ // remove leading/trailing/duplicate dividers
124
+ return fromPairs(
125
+ toPairs(result).map(([key, arr]) => {
126
+ let lastWasDivider = true;
127
+ return [
128
+ key,
129
+ arr
130
+ .map((row, index) => {
131
+ if (row.value === MenuSeparatorString) {
132
+ if (lastWasDivider) return null;
133
+ if (index === arr.length - 1) return null;
134
+ lastWasDivider = true;
135
+ } else {
136
+ lastWasDivider = false;
137
+ }
138
+ return row;
139
+ })
140
+ .filter((r) => r),
141
+ ];
142
+ }),
143
+ ) as HeaderGroupType;
144
+ }, [filter, options]);
124
145
 
125
146
  const headers: GridFormMultiSelectGroup[] = useMemo(() => props.headers ?? [{ header: "" }], [props.headers]);
126
147
 
@@ -136,7 +157,7 @@ export const GridFormMultiSelect = <RowType extends GridBaseRow>(props: GridForm
136
157
  <>
137
158
  {props.filtered && (
138
159
  <FilterInput
139
- {...{ headerGroups, options, setOptions, filter, setFilter }}
160
+ {...{ headerGroups, options, setOptions, filter, setFilter, triggerSave }}
140
161
  filterHelpText={props.filterHelpText}
141
162
  onSelectFilter={props.onSelectFilter}
142
163
  filterPlaceholder={props.filterPlaceholder}
@@ -185,12 +206,25 @@ const FilterInput = (props: {
185
206
  onSelectFilter?: (filter: string, options: MultiSelectOption[]) => void;
186
207
  filter: string;
187
208
  setFilter: Dispatch<SetStateAction<string>>;
188
- headerGroups: Record<string, MultiSelectOption[]> | undefined;
209
+ headerGroups: HeaderGroupType;
189
210
  filterPlaceholder?: string;
190
211
  filterHelpText?: string | ((filter: string, options: MultiSelectOption[]) => string | undefined);
212
+ triggerSave: () => Promise<void>;
191
213
  }) => {
192
- const { options, setOptions, onSelectFilter, filter, setFilter, headerGroups, filterPlaceholder, filterHelpText } =
193
- props;
214
+ const {
215
+ options,
216
+ setOptions,
217
+ onSelectFilter,
218
+ filter,
219
+ setFilter,
220
+ headerGroups,
221
+ filterPlaceholder,
222
+ filterHelpText,
223
+ triggerSave,
224
+ } = props;
225
+
226
+ const enterHasBeenPressed = useRef(false);
227
+ const lastKeyWasEnter = useRef(false);
194
228
 
195
229
  const toggleSelectAllVisible = useCallback(() => {
196
230
  if (!options || !headerGroups) return;
@@ -198,7 +232,9 @@ const FilterInput = (props: {
198
232
  if (isEmpty(filter.trim())) {
199
233
  // Toggle off if any items are checked otherwise on
200
234
  const anyChecked = options.some((o) => o.checked);
201
- options.forEach((o) => (o.checked = !anyChecked));
235
+ options.forEach((o) => {
236
+ if (o.label !== undefined) o.checked = !anyChecked;
237
+ });
202
238
  } else {
203
239
  // Toggle on if any filtered items are checked otherwise off
204
240
  const anyChecked = Object.values(headerGroups).some((headerOptions) =>
@@ -206,7 +242,7 @@ const FilterInput = (props: {
206
242
  );
207
243
  Object.values(headerGroups).forEach((headerOptions) => {
208
244
  headerOptions.forEach((o) => {
209
- if (o.checked !== undefined) o.checked = anyChecked;
245
+ if (o.label !== undefined) o.checked = anyChecked;
210
246
  });
211
247
  });
212
248
  }
@@ -216,14 +252,26 @@ const FilterInput = (props: {
216
252
  const addCustomFilterValue = useCallback(() => {
217
253
  if (!options || !onSelectFilter) return;
218
254
 
255
+ const filterTrimmed = filter.trim();
256
+ if (isEmpty(filterTrimmed)) {
257
+ triggerSave().then();
258
+ return;
259
+ }
260
+
219
261
  const preFilterOptions = JSON.stringify(options);
220
- onSelectFilter(filter.trim(), options);
262
+ onSelectFilter(filterTrimmed, options);
221
263
  // Detect if options list changed and update
222
264
  if (preFilterOptions === JSON.stringify(options)) return;
223
265
 
224
266
  setOptions([...options]);
225
267
  setFilter("");
226
- }, [filter, onSelectFilter, options, setFilter, setOptions]);
268
+ }, [filter, onSelectFilter, options, setFilter, setOptions, triggerSave]);
269
+
270
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
271
+ if (e.key === "Enter") {
272
+ enterHasBeenPressed.current = true;
273
+ }
274
+ }, []);
227
275
 
228
276
  const handleKeyUp = useCallback(
229
277
  (e: KeyboardEvent) => {
@@ -232,10 +280,23 @@ const FilterInput = (props: {
232
280
  e.preventDefault();
233
281
 
234
282
  if (e.ctrlKey) toggleSelectAllVisible();
235
- else if (onSelectFilter) addCustomFilterValue();
283
+ else if (enterHasBeenPressed.current) {
284
+ const filterTrimmed = filter.trim();
285
+ if (isEmpty(filterTrimmed)) {
286
+ triggerSave().then();
287
+ return;
288
+ }
289
+ onSelectFilter && addCustomFilterValue();
290
+ }
291
+ lastKeyWasEnter.current = true;
292
+ } else if (e.key === "Control") {
293
+ lastKeyWasEnter.current && setFilter("");
294
+ lastKeyWasEnter.current = false;
295
+ } else {
296
+ lastKeyWasEnter.current = false;
236
297
  }
237
298
  },
238
- [addCustomFilterValue, onSelectFilter, toggleSelectAllVisible],
299
+ [addCustomFilterValue, filter, onSelectFilter, setFilter, toggleSelectAllVisible, triggerSave],
239
300
  );
240
301
 
241
302
  return (
@@ -250,8 +311,9 @@ const FilterInput = (props: {
250
311
  data-testid={"filteredMenu-free-text-input"}
251
312
  value={filter}
252
313
  data-disableenterautosave={true}
253
- data-allowtabtoSave={true}
314
+ data-allowtabtosave={true}
254
315
  onChange={(e) => setFilter(e.target.value)}
316
+ onKeyDown={handleKeyDown}
255
317
  onKeyUp={handleKeyUp}
256
318
  />
257
319
  {filterHelpText && (
@@ -267,7 +329,7 @@ const FilterInput = (props: {
267
329
  </FocusableItem>
268
330
  <MenuDivider key={`$$divider_filter`} />
269
331
  {headerGroups && !toPairs(headerGroups).some(([_, options]) => !isEmpty(options)) && (
270
- <div className={"szh-menu__item"}>[No items match the filter]</div>
332
+ <div className={"szh-menu__item GridPopoverEditDropDown-noOptions"}>No Options</div>
271
333
  )}
272
334
  </>
273
335
  );
@@ -11,8 +11,8 @@ export const GridPopoverEditDropDown = <RowType extends GridBaseRow>(
11
11
  editor: GridFormDropDown,
12
12
  ...props,
13
13
  editorParams: {
14
- // Defaults to medium size container
15
- className: "GridPopoverEditDropDown-containerMedium",
14
+ // Defaults to large size container
15
+ className: "GridPopoverEditDropDown-containerLarge",
16
16
  ...(props.editorParams as GridFormPopoutDropDownProps<RowType>),
17
17
  },
18
18
  });
package/src/index.ts CHANGED
@@ -45,3 +45,4 @@ export * from "./lui/ActionButton";
45
45
 
46
46
  export * from "./utils/bearing";
47
47
  export * from "./utils/util";
48
+ export * from "./utils/testUtil";
@@ -138,7 +138,7 @@ export const ControlledMenuFr = (
138
138
 
139
139
  const isTextArea = activeElement.nodeName === "TEXTAREA";
140
140
  const suppressEnterAutoSave = activeElement.getAttribute("data-disableenterautosave") || isTextArea;
141
- const allowTabToSave = activeElement.getAttribute("data-allowtabtoSave");
141
+ const allowTabToSave = activeElement.getAttribute("data-allowtabtosave");
142
142
  const invokeSave = (reason: string) => {
143
143
  if (!saveButtonRef?.current) return;
144
144
  saveButtonRef.current?.setAttribute("data-reason", reason);
@@ -8,7 +8,7 @@ import { GridUpdatingContextProvider } from "../../contexts/GridUpdatingContextP
8
8
  import { GridContextProvider } from "../../contexts/GridContextProvider";
9
9
  import { Grid, GridProps } from "../../components/Grid";
10
10
  import { useMemo, useState } from "react";
11
- import { MenuHeaderItem, MenuSeparator } from "../../components/gridForm/GridFormDropDown";
11
+ import { MenuSeparator } from "../../components/gridForm/GridFormDropDown";
12
12
  import { GridFormSubComponentTextArea } from "../../components/gridForm/GridFormSubComponentTextArea";
13
13
  import { ColDefT, GridCell } from "../../components/GridCell";
14
14
  import { GridPopoutEditMultiSelect } from "../../components/gridPopoverEdit/GridPopoutEditMultiSelect";
@@ -85,8 +85,8 @@ const GridEditMultiSelectTemplate: ComponentStory<typeof Grid> = (props: GridPro
85
85
  filtered: true,
86
86
  filterPlaceholder: "Filter position",
87
87
  className: "GridMultiSelect-containerUnlimited",
88
+ headers: [{ header: "Header item" }],
88
89
  options: [
89
- MenuHeaderItem("Header item"),
90
90
  { value: "lot1", label: "Lot 1" },
91
91
  { value: "lot2", label: "Lot 2" },
92
92
  { value: "lot3", label: "Lot 3" },
@@ -0,0 +1,146 @@
1
+ /**
2
+ * General query all by selected. Internal use, use quick operations instead.
3
+ *
4
+ * @param selector Selector to use
5
+ * @param container Optional container
6
+ * @return HTMLElement array
7
+ */
8
+ import { IconName } from "@linzjs/lui/dist/components/LuiIcon/LuiIcon";
9
+ import { wait } from "./util";
10
+
11
+ const queryAllBySelector = <T extends HTMLElement>(selector: string, container: HTMLElement = document.body): T[] =>
12
+ Array.from(container.querySelectorAll<T>(selector));
13
+
14
+ /**
15
+ * Filters for query quick operations.
16
+ */
17
+ export interface IQueryQuick {
18
+ testId?: string;
19
+ tagName?: "button" | "span" | "div" | "input" | "textarea" | any;
20
+ textEquals?: string;
21
+ textContains?: string;
22
+ icon?: IconName;
23
+ ariaLabel?: string;
24
+ role?: string;
25
+ classes?: string;
26
+ child?: IQueryQuick;
27
+ }
28
+
29
+ const escapeSelectorParam = (param: string): string => param.replace(/["\\]/g, "\\$&");
30
+
31
+ /**
32
+ * Build selector for quick operations.
33
+ *
34
+ * @param props Filters to convert to selector.
35
+ * @param container Optional container to search in.
36
+ */
37
+ const quickSelector = <T extends HTMLElement>(
38
+ props: IQueryQuick,
39
+ container?: HTMLElement,
40
+ ): { selector: string; els: T[] } => {
41
+ let selector = "";
42
+ let lastIQueryQuick = props;
43
+ for (let loop: IQueryQuick | undefined = props; loop; loop = loop.child) {
44
+ lastIQueryQuick = loop;
45
+ loop.tagName && (selector += loop.tagName);
46
+ loop.ariaLabel && (selector += `[aria-label='${escapeSelectorParam(loop.ariaLabel)}']`);
47
+ loop.role && (selector += `[role="${escapeSelectorParam(loop.role)}"]`);
48
+ loop.testId && (selector += `[data-testid="${escapeSelectorParam(loop.testId)}"]`);
49
+ loop.icon && (selector += `[data-icon='${loop.icon}']`);
50
+ loop.classes && (selector += loop.classes);
51
+ selector += " ";
52
+ }
53
+
54
+ if (selector.trim() == "") {
55
+ throw "get/query/findQuick needs at least one defined parameter";
56
+ }
57
+
58
+ let els = queryAllBySelector<T>(selector, container);
59
+
60
+ const textMatch = lastIQueryQuick.textEquals?.toLowerCase();
61
+ if (textMatch != null) {
62
+ els = els.filter((el) => el.innerHTML.toLowerCase() === textMatch || el.innerText.toLowerCase() === textMatch);
63
+ }
64
+ const textContains = lastIQueryQuick.textContains?.toLowerCase();
65
+ if (textContains != null) {
66
+ els = els.filter(
67
+ (el) =>
68
+ el.innerHTML.toLowerCase().indexOf(textContains) != -1 ||
69
+ el.innerText.toLowerCase().indexOf(textContains) != -1,
70
+ );
71
+ }
72
+
73
+ return { selector, els };
74
+ };
75
+
76
+ /**
77
+ * Query by filter.
78
+ *
79
+ * @param filter Filter
80
+ * @param container Optional container to look in
81
+ * @return HTMLElement if found else null
82
+ */
83
+ export const queryQuick = <T extends HTMLElement>(filter: IQueryQuick, container?: HTMLElement): T | null => {
84
+ const { els, selector } = quickSelector<T>(filter, container);
85
+ if (els.length > 1) {
86
+ throw `Found multiple(${els.length}) elements by selector ${selector}`;
87
+ }
88
+ return els[0] ?? null;
89
+ };
90
+
91
+ /**
92
+ * Query all by filter.
93
+ *
94
+ * @param filter Filter
95
+ * @param container Optional container to look in
96
+ * @return HTMLElement array
97
+ */
98
+ export const queryAllQuick = <T extends HTMLElement>(filter: IQueryQuick, container?: HTMLElement): T[] =>
99
+ quickSelector<T>(filter, container)?.els;
100
+
101
+ /**
102
+ * Get all by filter.
103
+ *
104
+ * @param filter Filter
105
+ * @param container Optional container to look in
106
+ * @return HTMLElement array. Throws exception if nothing is found.
107
+ */
108
+ export const getAllQuick = <T extends HTMLElement>(filter: IQueryQuick, container?: HTMLElement): T[] => {
109
+ const els = queryAllQuick(filter, container);
110
+ if (els.length == 0) {
111
+ throw Error(`getAllQuick not found, selector: ${quickSelector(filter)}`);
112
+ }
113
+ return els as T[];
114
+ };
115
+
116
+ /**
117
+ * Get by filter.
118
+ *
119
+ * @param filter Filter
120
+ * @param container Optional container to look in
121
+ * @return HTMLElement. Throws exception if not found.
122
+ */
123
+ export const getQuick = (filter: IQueryQuick, container?: HTMLElement): HTMLElement => {
124
+ const el = queryQuick(filter, container);
125
+ if (el == null) {
126
+ throw Error(`getQuick not found, selector: ${quickSelector(filter).selector}`);
127
+ }
128
+ return el;
129
+ };
130
+
131
+ /**
132
+ * Find by filter. Waits up to 4 seconds to find filter.
133
+ *
134
+ * @param filter Filter
135
+ * @param container Optional container to look in
136
+ * @return HTMLElement. Throws exception if not found.
137
+ */
138
+ export const findQuick = async <T extends HTMLElement>(filter: IQueryQuick, container?: HTMLElement): Promise<T> => {
139
+ const endTime = Date.now() + 4000;
140
+ while (Date.now() < endTime) {
141
+ const el = queryQuick<T>(filter, container);
142
+ if (el) return el;
143
+ await wait(50);
144
+ }
145
+ throw Error(`findQuick not found, selector: ${quickSelector(filter).selector}`);
146
+ };
@@ -0,0 +1,135 @@
1
+ import { act, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { findQuick, getAllQuick, getQuick, queryQuick } from "./testQuick";
4
+
5
+ export const findRow = async (rowId: number | string, within?: HTMLElement): Promise<HTMLDivElement> => {
6
+ return findQuick<HTMLDivElement>({ tagName: `div[row-id='${rowId}']:not(:empty)` }, within);
7
+ };
8
+
9
+ export const queryRow = async (rowId: number | string, within?: HTMLElement): Promise<HTMLDivElement | null> => {
10
+ return queryQuick<HTMLDivElement>({ tagName: `div[row-id='${rowId}']:not(:empty)` }, within);
11
+ };
12
+
13
+ const _selectRow = async (
14
+ select: "select" | "deselect" | "toggle",
15
+ rowId: string | number,
16
+ within?: HTMLElement,
17
+ ): Promise<void> => {
18
+ await act(async () => {
19
+ const row = await findRow(rowId, within);
20
+ const isSelected = !row.className.includes("ag-row-selected");
21
+ if (select === "toggle" || (select === "select" && !isSelected) || (select === "deselect" && isSelected)) {
22
+ userEvent.click(row);
23
+ }
24
+ });
25
+ };
26
+
27
+ export const selectRow = async (rowId: string | number, within?: HTMLElement): Promise<void> =>
28
+ _selectRow("select", rowId, within);
29
+
30
+ export const deselectRow = async (rowId: string | number, within?: HTMLElement): Promise<void> =>
31
+ _selectRow("deselect", rowId, within);
32
+
33
+ export const findCell = async (rowId: number | string, colId: string, within?: HTMLElement): Promise<HTMLElement> => {
34
+ const row = await findRow(rowId, within);
35
+ return await findQuick({ tagName: `[col-id='${colId}']` }, row);
36
+ };
37
+
38
+ export const selectCell = async (rowId: string | number, colId: string, within?: HTMLElement): Promise<void> => {
39
+ await act(async () => {
40
+ const cell = await findCell(rowId, colId, within);
41
+ userEvent.click(cell);
42
+ });
43
+ };
44
+
45
+ export const editCell = async (rowId: number | string, colId: string, within?: HTMLElement): Promise<void> => {
46
+ await act(async () => {
47
+ const cell = await findCell(rowId, colId, within);
48
+ userEvent.dblClick(cell);
49
+ await findOpenMenu();
50
+ });
51
+ };
52
+
53
+ const findOpenMenu = async (): Promise<HTMLElement> => findQuick({ classes: ".szh-menu--state-open" });
54
+
55
+ export const queryMenuOption = async (menuOptionText: string): Promise<HTMLElement | null> => {
56
+ const openMenu = await findOpenMenu();
57
+ const els = await within(openMenu).findAllByRole("menuitem");
58
+ const result = els.find(
59
+ (e: HTMLElement) => e.innerHTML?.trim() === menuOptionText || e.innerText?.trim() === menuOptionText,
60
+ );
61
+ return result ?? null;
62
+ };
63
+
64
+ export const findMenuOption = async (menuOptionText: string): Promise<HTMLElement> => {
65
+ const menuOption = await queryMenuOption(menuOptionText);
66
+ if (menuOption == null) {
67
+ throw Error(`Unable to find menu option ${menuOptionText}`);
68
+ }
69
+ return menuOption;
70
+ };
71
+
72
+ export const clickMenuOption = async (menuOptionText: string): Promise<void> => {
73
+ await act(async () => {
74
+ const menuOption = await findMenuOption(menuOptionText);
75
+ menuOption && userEvent.click(menuOption);
76
+ });
77
+ };
78
+
79
+ export const openAndClickMenuOption = async (
80
+ rowId: number | string,
81
+ colId: string,
82
+ menuOptionText: string,
83
+ within?: HTMLElement,
84
+ ): Promise<void> => {
85
+ await editCell(rowId, colId, within);
86
+ await clickMenuOption(menuOptionText);
87
+ };
88
+
89
+ export const getMultiSelectOptions = async () => {
90
+ const openMenu = await findOpenMenu();
91
+ return getAllQuick<HTMLInputElement>({ role: "menuitem", child: { tagName: "input,textarea" } }, openMenu).map(
92
+ (input) => {
93
+ return {
94
+ v: input.value,
95
+ c: input.checked ?? true,
96
+ };
97
+ },
98
+ );
99
+ };
100
+
101
+ export const findMultiSelectOption = async (value: string): Promise<HTMLElement> => {
102
+ const openMenu = await findOpenMenu();
103
+ return getQuick({ role: "menuitem", child: { tagName: `input[value='${value}']` } }, openMenu);
104
+ };
105
+
106
+ export const clickMultiSelectOption = async (value: string): Promise<void> => {
107
+ const menuItem = await findMultiSelectOption(value);
108
+ menuItem.parentElement && userEvent.click(menuItem.parentElement);
109
+ };
110
+
111
+ export const typeOtherInput = async (value: string): Promise<void> => {
112
+ const openMenu = await findOpenMenu();
113
+ const otherInput = await findQuick({ tagName: "input[type='text']" }, openMenu);
114
+ userEvent.type(otherInput, value);
115
+ };
116
+
117
+ export const typeOtherTextArea = async (value: string): Promise<void> => {
118
+ const openMenu = await findOpenMenu();
119
+ const otherTextArea = await findQuick({ tagName: "textarea" }, openMenu);
120
+ userEvent.type(otherTextArea, value);
121
+ };
122
+
123
+ export const closeMenu = (): void => {
124
+ userEvent.click(document.body);
125
+ };
126
+
127
+ export const findActionButton = (text: string, container: HTMLElement): Promise<HTMLElement> =>
128
+ findQuick({ tagName: "button", child: { classes: ".ActionButton-minimalAreaDisplay", textEquals: text } }, container);
129
+
130
+ export const clickActionButton = async (text: string, container: HTMLElement): Promise<void> => {
131
+ await act(async () => {
132
+ const button = await findActionButton(text, container);
133
+ userEvent.click(button);
134
+ });
135
+ };
@@ -1,4 +1,4 @@
1
- import { isEmpty } from "lodash-es";
1
+ import { isEmpty, partition } from "lodash-es";
2
2
  import { isMatch } from "matcher";
3
3
 
4
4
  /**
@@ -10,8 +10,8 @@ import { isMatch } from "matcher";
10
10
  * "*L" => *L
11
11
  * "A B" => A* and B*
12
12
  * "A B, C" => (A* and B*) or C*
13
- *
14
- * Returns ture if there's a text match.
13
+ * "!A" => all values must not match A
14
+ * Returns true if there's a text match.
15
15
  */
16
16
  export const textMatch = (text: string | undefined | null, filter: string): boolean => {
17
17
  if (text == null) return true;
@@ -20,12 +20,15 @@ export const textMatch = (text: string | undefined | null, filter: string): bool
20
20
  .split(",")
21
21
  .map((sf) => sf.trim())
22
22
  .filter((sf) => sf);
23
+ const [negativeFilters, positiveFilters] = partition(superFilters, (superFilters) => superFilters.startsWith("!"));
23
24
  const values = text.replaceAll(",", " ").trim().split(/\s+/);
24
25
  return (
25
- isEmpty(superFilters) || // Not filtered
26
- superFilters.some((superFilter) => {
27
- const subFilters = superFilter.split(/\s+/).map((s) => s.replaceAll(/([!?])/g, "\\$1"));
28
- return subFilters.every((subFilter) => values.some((value) => isMatch(value, subFilter)));
29
- })
26
+ (isEmpty(positiveFilters) || // Not filtered
27
+ positiveFilters.some((superFilter) => {
28
+ const subFilters = superFilter.split(/\s+/).map((s) => s.replaceAll(/([!?])/g, "\\$1"));
29
+ return subFilters.every((subFilter) => values.some((value) => isMatch(value, subFilter)));
30
+ })) &&
31
+ (isEmpty(negativeFilters) ||
32
+ negativeFilters.every((superFilter) => values.every((value) => isMatch(value, superFilter))))
30
33
  );
31
34
  };