@servicetitan/table 23.4.0 → 24.0.0

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.
Files changed (57) hide show
  1. package/dist/demo/filters/async-select-filter.d.ts +3 -0
  2. package/dist/demo/filters/async-select-filter.d.ts.map +1 -0
  3. package/dist/demo/filters/async-select-filter.js +23 -0
  4. package/dist/demo/filters/async-select-filter.js.map +1 -0
  5. package/dist/demo/filters/categories.d.ts +9 -0
  6. package/dist/demo/filters/categories.d.ts.map +1 -0
  7. package/dist/demo/filters/categories.js +33 -0
  8. package/dist/demo/filters/categories.js.map +1 -0
  9. package/dist/demo/filters/single-select-filter.d.ts +3 -0
  10. package/dist/demo/filters/single-select-filter.d.ts.map +1 -0
  11. package/dist/demo/filters/single-select-filter.js +20 -0
  12. package/dist/demo/filters/single-select-filter.js.map +1 -0
  13. package/dist/demo/filters/table.store.d.ts +9 -0
  14. package/dist/demo/filters/table.store.d.ts.map +1 -0
  15. package/dist/demo/filters/table.store.js +73 -0
  16. package/dist/demo/filters/table.store.js.map +1 -0
  17. package/dist/filters/async-select/async-select-filter.d.ts +19 -0
  18. package/dist/filters/async-select/async-select-filter.d.ts.map +1 -0
  19. package/dist/filters/async-select/async-select-filter.js +168 -0
  20. package/dist/filters/async-select/async-select-filter.js.map +1 -0
  21. package/dist/filters/async-select/async-select-filter.stories.d.ts +7 -0
  22. package/dist/filters/async-select/async-select-filter.stories.d.ts.map +1 -0
  23. package/dist/filters/async-select/async-select-filter.stories.js +7 -0
  24. package/dist/filters/async-select/async-select-filter.stories.js.map +1 -0
  25. package/dist/filters/index.d.ts +2 -0
  26. package/dist/filters/index.d.ts.map +1 -1
  27. package/dist/filters/index.js +2 -0
  28. package/dist/filters/index.js.map +1 -1
  29. package/dist/filters/single-select/single-select-filter.d.ts +9 -0
  30. package/dist/filters/single-select/single-select-filter.d.ts.map +1 -0
  31. package/dist/filters/single-select/single-select-filter.js +30 -0
  32. package/dist/filters/single-select/single-select-filter.js.map +1 -0
  33. package/dist/filters/single-select/single-select-filter.stories.d.ts +7 -0
  34. package/dist/filters/single-select/single-select-filter.stories.d.ts.map +1 -0
  35. package/dist/filters/single-select/single-select-filter.stories.js +7 -0
  36. package/dist/filters/single-select/single-select-filter.stories.js.map +1 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/utils/filters.d.ts +4 -0
  42. package/dist/utils/filters.d.ts.map +1 -0
  43. package/dist/utils/filters.js +22 -0
  44. package/dist/utils/filters.js.map +1 -0
  45. package/package.json +6 -6
  46. package/src/demo/filters/async-select-filter.tsx +53 -0
  47. package/src/demo/filters/categories.tsx +40 -0
  48. package/src/demo/filters/single-select-filter.tsx +51 -0
  49. package/src/demo/filters/table.store.ts +45 -0
  50. package/src/filters/async-select/async-select-filter.stories.tsx +7 -0
  51. package/src/filters/async-select/async-select-filter.tsx +249 -0
  52. package/src/filters/index.ts +2 -0
  53. package/src/filters/single-select/single-select-filter.stories.tsx +7 -0
  54. package/src/filters/single-select/single-select-filter.tsx +66 -0
  55. package/src/index.ts +1 -0
  56. package/src/utils/__tests__/filters.test.ts +31 -0
  57. package/src/utils/filters.ts +33 -0
@@ -0,0 +1,22 @@
1
+ import { isCompositeFilterDescriptor, } from '@servicetitan/data-query';
2
+ const getFiltersFlatInner = (filter) => {
3
+ if (!filter) {
4
+ return [];
5
+ }
6
+ else if (isCompositeFilterDescriptor(filter)) {
7
+ return filter.filters.reduce((out, item) => [...out, ...getFiltersFlatInner(item)], []);
8
+ }
9
+ return [filter];
10
+ };
11
+ export const getFiltersFlat = (filter) => {
12
+ return getFiltersFlatInner(filter);
13
+ };
14
+ export const getFiltersMap = (filter) => {
15
+ return getFiltersFlatInner(filter).reduce((out, filter) => {
16
+ if (typeof filter.field === 'string') {
17
+ out[filter.field] = filter;
18
+ }
19
+ return out;
20
+ }, {});
21
+ };
22
+ //# sourceMappingURL=filters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filters.js","sourceRoot":"","sources":["../../src/utils/filters.ts"],"names":[],"mappings":"AAAA,OAAO,EAGH,2BAA2B,GAE9B,MAAM,0BAA0B,CAAC;AAElC,MAAM,mBAAmB,GAAG,CACxB,MAAqD,EACnC,EAAE;IACpB,IAAI,CAAC,MAAM,EAAE;QACT,OAAO,EAAE,CAAC;KACb;SAAM,IAAI,2BAA2B,CAAC,MAAM,CAAC,EAAE;QAC5C,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CACxB,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC,EACrD,EAAwB,CAC3B,CAAC;KACL;IACD,OAAO,CAAC,MAAM,CAAC,CAAC;AACpB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,MAAuB,EAAsB,EAAE;IAC1E,OAAO,mBAAmB,CAAC,MAAM,CAAC,CAAC;AACvC,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,MAAuB,EAAoC,EAAE;IACvF,OAAO,mBAAmB,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE;QACtD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,EAAE;YAClC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC;SAC9B;QACD,OAAO,GAAG,CAAC;IACf,CAAC,EAAE,EAAsC,CAAC,CAAC;AAC/C,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/table",
3
- "version": "23.4.0",
3
+ "version": "24.0.0",
4
4
  "description": "",
5
5
  "homepage": "https://docs.st.dev/docs/frontend/table",
6
6
  "repository": {
@@ -37,9 +37,9 @@
37
37
  "memoize-one": "~6.0.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@servicetitan/data-query": "^23.4.0",
40
+ "@servicetitan/data-query": "^24.0.0",
41
41
  "@servicetitan/design-system": ">=12.4.1",
42
- "@servicetitan/form": "^23.4.0",
42
+ "@servicetitan/form": "^24.0.0",
43
43
  "@servicetitan/react-ioc": "^21.6.0",
44
44
  "@servicetitan/suppress-warnings": "^21.6.0",
45
45
  "@types/accounting": "~0.4.2",
@@ -53,9 +53,9 @@
53
53
  "react": "~17.0.2"
54
54
  },
55
55
  "peerDependencies": {
56
- "@servicetitan/data-query": "^23.4.0",
56
+ "@servicetitan/data-query": "^24.0.0",
57
57
  "@servicetitan/design-system": ">=12.4.1",
58
- "@servicetitan/form": "^23.4.0",
58
+ "@servicetitan/form": "^24.0.0",
59
59
  "@servicetitan/react-ioc": ">21.0.0",
60
60
  "@servicetitan/suppress-warnings": ">21.0.0",
61
61
  "accounting": "~0.4.1",
@@ -72,5 +72,5 @@
72
72
  "cli": {
73
73
  "webpack": false
74
74
  },
75
- "gitHead": "1acc525c3516017ff8dc26cfe8a3988ec76678d4"
75
+ "gitHead": "136cde3c32f5798d1c73dd456b16bd691d0cf257"
76
76
  }
@@ -0,0 +1,53 @@
1
+ import { useMemo, FC } from 'react';
2
+ import { observer } from 'mobx-react';
3
+ import { provide, useDependencies } from '@servicetitan/react-ioc';
4
+
5
+ import { Table, TableColumn, asyncSelectColumnMenuFilter } from '../..';
6
+
7
+ import { TableStore } from './table.store';
8
+ import { CategoryCell } from './categories';
9
+
10
+ export const TableExample: FC = provide({
11
+ singletons: [TableStore],
12
+ })(
13
+ observer(() => {
14
+ const [{ categoryFetcher, madeInFetcher, tableState }] = useDependencies(TableStore);
15
+
16
+ const madeInColumnMenu = useMemo(
17
+ () =>
18
+ asyncSelectColumnMenuFilter({
19
+ dataFetcher: madeInFetcher,
20
+ placeholder: 'Search for made-ins',
21
+ }),
22
+ [madeInFetcher]
23
+ );
24
+
25
+ const categoryColumnMenu = useMemo(
26
+ () =>
27
+ asyncSelectColumnMenuFilter({
28
+ dataFetcher: categoryFetcher,
29
+ placeholder: 'Search for Categories',
30
+ multiple: true,
31
+ }),
32
+ [categoryFetcher]
33
+ );
34
+
35
+ return (
36
+ <Table tableState={tableState} striped={false}>
37
+ <TableColumn field="ProductID" title="ID" editable={false} width="100px" />
38
+ <TableColumn field="ProductName" title="Product Name" width="240px" />
39
+ <TableColumn
40
+ field="MadeIn"
41
+ title="Made In (async single-select filter)"
42
+ columnMenu={madeInColumnMenu}
43
+ />
44
+ <TableColumn
45
+ field="CategoryID"
46
+ title="Category (async multi-select filter)"
47
+ columnMenu={categoryColumnMenu}
48
+ cell={CategoryCell}
49
+ />
50
+ </Table>
51
+ );
52
+ })
53
+ );
@@ -0,0 +1,40 @@
1
+ import { FC } from 'react';
2
+ import { TableCellProps } from '../../table';
3
+
4
+ export interface Category {
5
+ CategoryID: number;
6
+ CategoryName: string;
7
+ }
8
+
9
+ export const categories: Category[] = [
10
+ {
11
+ CategoryID: 1,
12
+ CategoryName: 'Wok',
13
+ },
14
+ {
15
+ CategoryID: 2,
16
+ CategoryName: 'Sushi',
17
+ },
18
+ {
19
+ CategoryID: 6,
20
+ CategoryName: 'Gunkan',
21
+ },
22
+ {
23
+ CategoryID: 7,
24
+ CategoryName: 'Miso',
25
+ },
26
+ {
27
+ CategoryID: 8,
28
+ CategoryName: 'Roll',
29
+ },
30
+ {
31
+ CategoryID: 10,
32
+ CategoryName: 'Sashimi',
33
+ },
34
+ ];
35
+
36
+ export const CategoryCell: FC<TableCellProps> = props => {
37
+ const { field, dataItem } = props;
38
+
39
+ return <td>{categories.find(c => c.CategoryID === dataItem[field!])?.CategoryName}</td>;
40
+ };
@@ -0,0 +1,51 @@
1
+ import { useMemo, FC } from 'react';
2
+
3
+ import { provide, useDependencies } from '@servicetitan/react-ioc';
4
+
5
+ import { observer } from 'mobx-react';
6
+
7
+ import { Table, TableColumn, singleSelectColumnMenuFilter } from '../..';
8
+
9
+ import { TableStore } from './table.store';
10
+ import { categories, CategoryCell } from './categories';
11
+
12
+ export const TableExample: FC = provide({
13
+ singletons: [TableStore],
14
+ })(
15
+ observer(() => {
16
+ const [{ tableState, madeInOptions }] = useDependencies(TableStore);
17
+
18
+ const madeInColumnMenu = useMemo(
19
+ () => singleSelectColumnMenuFilter({ options: madeInOptions }),
20
+ [madeInOptions]
21
+ );
22
+
23
+ const categoryColumnMenu = useMemo(
24
+ () =>
25
+ singleSelectColumnMenuFilter({
26
+ options: categories,
27
+ renderItem: cat => cat.CategoryName,
28
+ valueSelector: cat => cat.CategoryID,
29
+ }),
30
+ []
31
+ );
32
+
33
+ return (
34
+ <Table tableState={tableState} striped={false}>
35
+ <TableColumn field="ProductID" title="ID" editable={false} width="100px" />
36
+ <TableColumn field="ProductName" title="Product Name" width="240px" />
37
+ <TableColumn
38
+ field="MadeIn"
39
+ title="Made In (simple single-select filter)"
40
+ columnMenu={madeInColumnMenu}
41
+ />
42
+ <TableColumn
43
+ field="CategoryID"
44
+ title="Category (simple single-select filter)"
45
+ columnMenu={categoryColumnMenu}
46
+ cell={CategoryCell}
47
+ />
48
+ </Table>
49
+ );
50
+ })
51
+ );
@@ -0,0 +1,45 @@
1
+ import { injectable } from '@servicetitan/react-ioc';
2
+
3
+ import { InMemoryDataSource, TableState, AsyncSelectFilterDataFetcher } from '../..';
4
+ import { products } from '../overview/products';
5
+ import { categories } from './categories';
6
+
7
+ @injectable()
8
+ export class TableStore {
9
+ tableState = new TableState({
10
+ dataSource: this.getDataSource(),
11
+ pageSize: 5,
12
+ });
13
+
14
+ get madeInOptions() {
15
+ return Array.from(new Set(products.map(p => p.MadeIn)));
16
+ }
17
+
18
+ categoryFetcher: AsyncSelectFilterDataFetcher<number> = async opts => {
19
+ await new Promise(resolve => setTimeout(resolve, 1500));
20
+
21
+ const sv = opts.search?.trim().toLowerCase();
22
+
23
+ return {
24
+ data: categories
25
+ .filter(cat => (sv ? cat.CategoryName.toLowerCase().includes(sv) : true))
26
+ .map(cat => ({ value: cat.CategoryID, text: cat.CategoryName })),
27
+ };
28
+ };
29
+
30
+ madeInFetcher: AsyncSelectFilterDataFetcher<string> = async opts => {
31
+ await new Promise(resolve => setTimeout(resolve, 1500));
32
+
33
+ const sv = opts.search?.trim().toLowerCase();
34
+
35
+ return {
36
+ data: this.madeInOptions
37
+ .filter(opt => (sv ? opt.toLowerCase().includes(sv) : true))
38
+ .map(opt => ({ value: opt, text: opt })),
39
+ };
40
+ };
41
+
42
+ private getDataSource() {
43
+ return new InMemoryDataSource(products, row => row.ProductID);
44
+ }
45
+ }
@@ -0,0 +1,7 @@
1
+ import { TableExample } from '../../demo/filters/async-select-filter';
2
+
3
+ export default {
4
+ title: 'Table/Filters',
5
+ };
6
+
7
+ export const AsyncSelect = () => <TableExample />;
@@ -0,0 +1,249 @@
1
+ import { Component, SyntheticEvent, ReactNode, Fragment, KeyboardEvent, FC } from 'react';
2
+
3
+ import {
4
+ Checkbox,
5
+ TableFilterCellProps,
6
+ Radio,
7
+ Input,
8
+ Spinner,
9
+ BodyText,
10
+ } from '@servicetitan/design-system';
11
+ import { IdType } from '@servicetitan/data-query';
12
+
13
+ import { renderCustomColumnMenuFilter } from '../column-menu-filters';
14
+ import { makeObservable, observable, runInAction } from 'mobx';
15
+ import { observer } from 'mobx-react';
16
+
17
+ export type AsyncSelectFilterDataFetcher<
18
+ TV extends IdType = IdType,
19
+ TO extends AsyncSelectItem<TV> = AsyncSelectItem<TV>
20
+ > = (opts: { search?: string }) => Promise<{ data: TO[] }>;
21
+
22
+ export interface AsyncSelectItem<TV extends IdType = IdType> {
23
+ value: TV;
24
+ text: string;
25
+ }
26
+
27
+ interface SelectorProps<TID extends IdType, TO extends AsyncSelectItem<TID>> {
28
+ placeholder: string;
29
+ selected: TO[];
30
+ dataFetcher: AsyncSelectFilterDataFetcher<TID, TO>;
31
+ itemComponent: FC<SelectorItemProps>;
32
+ onChange(option: TO, checked: boolean, event: SyntheticEvent<HTMLInputElement>): void;
33
+ renderer?(item: TO): ReactNode;
34
+ }
35
+
36
+ interface SelectorItemProps {
37
+ option: AsyncSelectItem;
38
+ checked: boolean;
39
+ onChange(
40
+ option: AsyncSelectItem,
41
+ checked: boolean,
42
+ event: SyntheticEvent<HTMLInputElement>
43
+ ): void;
44
+ renderer?(item: AsyncSelectItem): ReactNode;
45
+ }
46
+
47
+ @observer
48
+ class SelectorAsync<TID extends IdType, TO extends AsyncSelectItem<TID>> extends Component<
49
+ SelectorProps<TID, TO>
50
+ > {
51
+ @observable shownOptions: AsyncSelectItem<TID>[] = [];
52
+ @observable search = '';
53
+ @observable error = false;
54
+ @observable loading = false;
55
+
56
+ constructor(props: SelectorProps<TID, TO>) {
57
+ super(props);
58
+ makeObservable(this);
59
+ }
60
+
61
+ componentDidMount() {
62
+ this.searchOptions().catch();
63
+ }
64
+
65
+ handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
66
+ if (event.key === 'Enter') {
67
+ event.stopPropagation();
68
+ }
69
+ };
70
+
71
+ handleSearch = (_0: SyntheticEvent<HTMLInputElement>, data: { value: string }) => {
72
+ runInAction(() => (this.search = data.value));
73
+ this.searchOptions().catch();
74
+ };
75
+
76
+ searchOptions = async () => {
77
+ runInAction(() => {
78
+ this.loading = true;
79
+ });
80
+
81
+ try {
82
+ const { data } = await this.props.dataFetcher({
83
+ search: this.search,
84
+ });
85
+
86
+ runInAction(() => {
87
+ this.shownOptions = data;
88
+ this.error = false;
89
+ this.loading = false;
90
+ });
91
+ } catch {
92
+ runInAction(() => {
93
+ this.error = true;
94
+ this.loading = false;
95
+ });
96
+ }
97
+ };
98
+
99
+ render() {
100
+ const selectedOptions = this.props.selected;
101
+ const selected = new Set(selectedOptions.map(opt => opt.value));
102
+ const ItemComponent = this.props.itemComponent;
103
+
104
+ return (
105
+ <Fragment>
106
+ <Input
107
+ className="m-x-half m-t-half m-b-2"
108
+ placeholder={this.props.placeholder}
109
+ onChange={this.handleSearch}
110
+ onKeyDown={this.handleKeyDown}
111
+ size="xsmall"
112
+ value={this.search}
113
+ />
114
+ <div className="p-x-half position-relative" onKeyDown={this.handleKeyDown}>
115
+ {!!selectedOptions.length && (
116
+ <div className="border-bottom m-y-half">
117
+ {selectedOptions.map(option => (
118
+ <ItemComponent
119
+ key={option.value}
120
+ option={option}
121
+ checked
122
+ renderer={this.props.renderer}
123
+ onChange={this.props.onChange}
124
+ />
125
+ ))}
126
+ </div>
127
+ )}
128
+ {this.error ? (
129
+ <BodyText className="c-red-500">Unable to load options</BodyText>
130
+ ) : this.shownOptions.length ? (
131
+ this.shownOptions
132
+ .filter(opt => !selected.has(opt.value))
133
+ .map(option => (
134
+ <ItemComponent
135
+ key={option.value}
136
+ option={option}
137
+ checked={false}
138
+ renderer={this.props.renderer}
139
+ onChange={this.props.onChange}
140
+ />
141
+ ))
142
+ ) : this.loading ? (
143
+ <BodyText>&nbsp;</BodyText>
144
+ ) : (
145
+ <BodyText>No options found</BodyText>
146
+ )}
147
+
148
+ {this.loading && (
149
+ <div className="position-absolute top-0 bottom-0 left-0 right-0 opacity-disabled bg-white d-f justify-content-center">
150
+ <Spinner size="tiny" />
151
+ </div>
152
+ )}
153
+ </div>
154
+ </Fragment>
155
+ );
156
+ }
157
+ }
158
+
159
+ const SelectorItemSingle: FC<SelectorItemProps> = ({ option, renderer, checked, onChange }) => (
160
+ <Radio
161
+ label={renderer?.(option) ?? option.text}
162
+ checked={checked}
163
+ onChange={(_, event) => onChange(option, true, event)}
164
+ className="m-b-1"
165
+ />
166
+ );
167
+
168
+ const SelectorItemMultiple: FC<SelectorItemProps> = ({ option, renderer, checked, onChange }) => (
169
+ <Checkbox
170
+ label={renderer?.(option) ?? option.text}
171
+ checked={checked}
172
+ onChange={(_, checked, event) => onChange(option, checked, event)}
173
+ className="m-b-1"
174
+ />
175
+ );
176
+
177
+ export interface AsyncSelectFilterOptions<TID extends IdType, TO extends AsyncSelectItem<TID>> {
178
+ dataFetcher: AsyncSelectFilterDataFetcher<TID, TO>;
179
+ placeholder: string;
180
+ multiple?: boolean;
181
+ renderItem?: (item: TO) => ReactNode;
182
+ }
183
+
184
+ interface TableFilterCellPropsTyped<T = any> extends Omit<TableFilterCellProps, 'value'> {
185
+ value?: T;
186
+ }
187
+
188
+ export function asyncSelectColumnMenuFilter<TID extends IdType, TO extends AsyncSelectItem<TID>>(
189
+ options: AsyncSelectFilterOptions<TID, TO>
190
+ ) {
191
+ const contains = (value: TID, options?: TO[]) => options?.some(opt => opt.value === value);
192
+ const equals = (value: TID, option?: TO) => option?.value === value;
193
+
194
+ const FilterCell = options.multiple
195
+ ? ({ value, onChange }: TableFilterCellPropsTyped<TO[]>) => {
196
+ const handleChange = (
197
+ option: TO,
198
+ checked: boolean,
199
+ event: SyntheticEvent<HTMLInputElement>
200
+ ) => {
201
+ const val = checked
202
+ ? (value ?? []).concat(option)
203
+ : (value ?? []).filter(opt => opt.value !== option.value);
204
+
205
+ onChange({
206
+ value: val.length ? val : undefined,
207
+ operator: val.length ? contains : '',
208
+ syntheticEvent: event,
209
+ });
210
+ };
211
+
212
+ return (
213
+ <SelectorAsync
214
+ selected={value ?? []}
215
+ itemComponent={SelectorItemMultiple}
216
+ onChange={handleChange}
217
+ placeholder={options.placeholder}
218
+ renderer={options.renderItem}
219
+ dataFetcher={options.dataFetcher}
220
+ />
221
+ );
222
+ }
223
+ : ({ value, onChange }: TableFilterCellPropsTyped<TO>) => {
224
+ const handleChange = (
225
+ option: TO,
226
+ checked: boolean,
227
+ event: SyntheticEvent<HTMLInputElement>
228
+ ) => {
229
+ onChange({
230
+ value: option,
231
+ operator: option ? equals : '',
232
+ syntheticEvent: event,
233
+ });
234
+ };
235
+
236
+ return (
237
+ <SelectorAsync
238
+ selected={value ? [value] : []}
239
+ onChange={handleChange}
240
+ itemComponent={SelectorItemSingle}
241
+ placeholder={options.placeholder}
242
+ renderer={options.renderItem}
243
+ dataFetcher={options.dataFetcher}
244
+ />
245
+ );
246
+ };
247
+
248
+ return renderCustomColumnMenuFilter(FilterCell);
249
+ }
@@ -6,3 +6,5 @@ export * from './column-menu-filters';
6
6
  export * from './field-values-filter';
7
7
  export * from './multiselect-filter';
8
8
  export * from './numeric-filter-extended/numeric-filter-extended';
9
+ export * from './single-select/single-select-filter';
10
+ export * from './async-select/async-select-filter';
@@ -0,0 +1,7 @@
1
+ import { TableExample } from '../../demo/filters/single-select-filter';
2
+
3
+ export default {
4
+ title: 'Table/Filters',
5
+ };
6
+
7
+ export const SingleSelect = () => <TableExample />;
@@ -0,0 +1,66 @@
1
+ import { Component, SyntheticEvent, ReactNode, FC } from 'react';
2
+
3
+ import { TableFilterCellProps, Radio } from '@servicetitan/design-system';
4
+
5
+ import { renderCustomColumnMenuFilter } from '../column-menu-filters';
6
+ import { IdType } from '@servicetitan/data-query';
7
+
8
+ interface SelectorProps<TV, TO> {
9
+ options: TO[];
10
+ value?: TV;
11
+ valueSelector(item: TO): TV;
12
+ onChange(value: TV | undefined, event: SyntheticEvent<HTMLInputElement>): void;
13
+ renderer?(item: TO): ReactNode;
14
+ }
15
+
16
+ class SelectorRadio<TV, TO> extends Component<SelectorProps<TV, TO>> {
17
+ render() {
18
+ const { options, value, renderer } = this.props;
19
+
20
+ return options.map((option, index) => {
21
+ const optionValue = this.props.valueSelector(option);
22
+ return (
23
+ // eslint-disable-next-line react/no-array-index-key
24
+ <span key={index} className="k-widget m-b-1-i d-b">
25
+ <Radio
26
+ label={renderer ? renderer(option) : `${option}`}
27
+ checked={value === optionValue}
28
+ onChange={(_, event) => this.props.onChange(optionValue, event)}
29
+ />
30
+ </span>
31
+ );
32
+ });
33
+ }
34
+ }
35
+
36
+ export interface SingleSelectColumnMenuOptions<TV, TO = TV> {
37
+ options: TO[];
38
+ valueSelector?: (item: TO) => TV;
39
+ renderItem?: (item: TO) => ReactNode | string;
40
+ }
41
+
42
+ export function singleSelectColumnMenuFilter<TV extends IdType, TO = TV>(
43
+ props: SingleSelectColumnMenuOptions<TV, TO>
44
+ ) {
45
+ const FilterCell: FC<TableFilterCellProps> = ({ value, onChange }) => {
46
+ const handleChange = (value: TV | undefined, event: SyntheticEvent<HTMLInputElement>) => {
47
+ onChange({
48
+ value: value ?? '',
49
+ operator: value ? 'equals' : '',
50
+ syntheticEvent: event,
51
+ });
52
+ };
53
+
54
+ return (
55
+ <SelectorRadio
56
+ options={props.options}
57
+ value={value === '' ? undefined : value}
58
+ onChange={handleChange}
59
+ renderer={props.renderItem}
60
+ valueSelector={props.valueSelector ?? (option => option)}
61
+ />
62
+ );
63
+ };
64
+
65
+ return renderCustomColumnMenuFilter(FilterCell);
66
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './filters';
5
5
  export * from './export';
6
6
  export * from './select-cell';
7
7
  export * from './use-observing-table-state';
8
+ export * from './utils/filters';
8
9
 
9
10
  export {
10
11
  TableColumn,
@@ -0,0 +1,31 @@
1
+ import { getFiltersFlat, getFiltersMap } from '../filters';
2
+ import { CompositeFilterDescriptor, FilterDescriptor } from '@servicetitan/data-query';
3
+
4
+ const filter = (field: string) => ({ field, operator: 'qq', value: 'ee' });
5
+ const composite = (
6
+ filters: (FilterDescriptor | CompositeFilterDescriptor)[]
7
+ ): CompositeFilterDescriptor => ({ filters, logic: 'and' });
8
+
9
+ const filters1 = () =>
10
+ composite([composite([filter('f1'), filter('f2')]), filter('f3'), composite([filter('f1')])]);
11
+
12
+ describe('getFiltersFlat', () => {
13
+ test('getFiltersFlat', () => {
14
+ expect(getFiltersFlat(filters1())).toEqual([
15
+ filter('f1'),
16
+ filter('f2'),
17
+ filter('f3'),
18
+ filter('f1'),
19
+ ]);
20
+ });
21
+ });
22
+
23
+ describe('getFiltersMap', () => {
24
+ test('getFiltersMap', () => {
25
+ expect(getFiltersMap(filters1())).toEqual({
26
+ f1: filter('f1'),
27
+ f2: filter('f2'),
28
+ f3: filter('f3'),
29
+ });
30
+ });
31
+ });
@@ -0,0 +1,33 @@
1
+ import {
2
+ CompositeFilterDescriptor,
3
+ FilterDescriptor,
4
+ isCompositeFilterDescriptor,
5
+ State,
6
+ } from '@servicetitan/data-query';
7
+
8
+ const getFiltersFlatInner = (
9
+ filter?: FilterDescriptor | CompositeFilterDescriptor
10
+ ): FilterDescriptor[] => {
11
+ if (!filter) {
12
+ return [];
13
+ } else if (isCompositeFilterDescriptor(filter)) {
14
+ return filter.filters.reduce(
15
+ (out, item) => [...out, ...getFiltersFlatInner(item)],
16
+ [] as FilterDescriptor[]
17
+ );
18
+ }
19
+ return [filter];
20
+ };
21
+
22
+ export const getFiltersFlat = (filter: State['filter']): FilterDescriptor[] => {
23
+ return getFiltersFlatInner(filter);
24
+ };
25
+
26
+ export const getFiltersMap = (filter: State['filter']): Record<string, FilterDescriptor> => {
27
+ return getFiltersFlatInner(filter).reduce((out, filter) => {
28
+ if (typeof filter.field === 'string') {
29
+ out[filter.field] = filter;
30
+ }
31
+ return out;
32
+ }, {} as Record<string, FilterDescriptor>);
33
+ };