@servicetitan/table 25.1.0 → 25.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (88) hide show
  1. package/dist/demo/filters/async-select-filter.js +1 -1
  2. package/dist/demo/filters/async-select-filter.js.map +1 -1
  3. package/dist/demo/filters/categories.d.ts.map +1 -1
  4. package/dist/demo/filters/categories.js +24 -0
  5. package/dist/demo/filters/categories.js.map +1 -1
  6. package/dist/demo/filters/multiselect-filter.d.ts.map +1 -1
  7. package/dist/demo/filters/multiselect-filter.js +2 -1
  8. package/dist/demo/filters/multiselect-filter.js.map +1 -1
  9. package/dist/demo/filters/select-filter.d.ts +6 -0
  10. package/dist/demo/filters/select-filter.d.ts.map +1 -0
  11. package/dist/demo/filters/select-filter.js +58 -0
  12. package/dist/demo/filters/select-filter.js.map +1 -0
  13. package/dist/demo/filters/single-select-filter.d.ts.map +1 -1
  14. package/dist/demo/filters/single-select-filter.js +2 -1
  15. package/dist/demo/filters/single-select-filter.js.map +1 -1
  16. package/dist/demo/filters/table.store.d.ts +8 -1
  17. package/dist/demo/filters/table.store.d.ts.map +1 -1
  18. package/dist/demo/filters/table.store.js +35 -1
  19. package/dist/demo/filters/table.store.js.map +1 -1
  20. package/dist/demo/overview/product.d.ts +5 -0
  21. package/dist/demo/overview/product.d.ts.map +1 -1
  22. package/dist/demo/overview/products.d.ts.map +1 -1
  23. package/dist/demo/overview/products.js +12 -0
  24. package/dist/demo/overview/products.js.map +1 -1
  25. package/dist/demo/overview/table.store.d.ts.map +1 -1
  26. package/dist/demo/overview/table.store.js +1 -0
  27. package/dist/demo/overview/table.store.js.map +1 -1
  28. package/dist/filters/async-select/async-select-filter.d.ts +7 -3
  29. package/dist/filters/async-select/async-select-filter.d.ts.map +1 -1
  30. package/dist/filters/async-select/async-select-filter.js +21 -6
  31. package/dist/filters/async-select/async-select-filter.js.map +1 -1
  32. package/dist/filters/column-menu-filters.d.ts +10 -1
  33. package/dist/filters/column-menu-filters.d.ts.map +1 -1
  34. package/dist/filters/column-menu-filters.js +11 -3
  35. package/dist/filters/column-menu-filters.js.map +1 -1
  36. package/dist/filters/index.d.ts +1 -0
  37. package/dist/filters/index.d.ts.map +1 -1
  38. package/dist/filters/index.js +1 -0
  39. package/dist/filters/index.js.map +1 -1
  40. package/dist/filters/multiselect-filter/multiselect-filter.d.ts +4 -3
  41. package/dist/filters/multiselect-filter/multiselect-filter.d.ts.map +1 -1
  42. package/dist/filters/multiselect-filter/multiselect-filter.js +7 -7
  43. package/dist/filters/multiselect-filter/multiselect-filter.js.map +1 -1
  44. package/dist/filters/select-filter/object-search.d.ts +2 -0
  45. package/dist/filters/select-filter/object-search.d.ts.map +1 -0
  46. package/dist/filters/select-filter/object-search.js +19 -0
  47. package/dist/filters/select-filter/object-search.js.map +1 -0
  48. package/dist/filters/select-filter/select-filter.d.ts +32 -0
  49. package/dist/filters/select-filter/select-filter.d.ts.map +1 -0
  50. package/dist/filters/select-filter/select-filter.js +214 -0
  51. package/dist/filters/select-filter/select-filter.js.map +1 -0
  52. package/dist/filters/select-filter/select-filter.stories.d.ts +8 -0
  53. package/dist/filters/select-filter/select-filter.stories.d.ts.map +1 -0
  54. package/dist/filters/select-filter/select-filter.stories.js +8 -0
  55. package/dist/filters/select-filter/select-filter.stories.js.map +1 -0
  56. package/dist/filters/select-filter/value-getter.d.ts +3 -0
  57. package/dist/filters/select-filter/value-getter.d.ts.map +1 -0
  58. package/dist/filters/select-filter/value-getter.js +10 -0
  59. package/dist/filters/select-filter/value-getter.js.map +1 -0
  60. package/dist/filters/single-select/single-select-filter.d.ts +3 -2
  61. package/dist/filters/single-select/single-select-filter.d.ts.map +1 -1
  62. package/dist/filters/single-select/single-select-filter.js +17 -7
  63. package/dist/filters/single-select/single-select-filter.js.map +1 -1
  64. package/dist/index.d.ts +1 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -1
  67. package/dist/index.js.map +1 -1
  68. package/package.json +6 -6
  69. package/src/demo/filters/async-select-filter.tsx +1 -1
  70. package/src/demo/filters/categories.tsx +24 -0
  71. package/src/demo/filters/multiselect-filter.tsx +15 -0
  72. package/src/demo/filters/select-filter.tsx +147 -0
  73. package/src/demo/filters/single-select-filter.tsx +2 -1
  74. package/src/demo/filters/table.store.ts +45 -2
  75. package/src/demo/overview/product.ts +6 -0
  76. package/src/demo/overview/products.ts +12 -0
  77. package/src/demo/overview/table.store.ts +1 -0
  78. package/src/filters/async-select/async-select-filter.tsx +26 -15
  79. package/src/filters/column-menu-filters.tsx +44 -21
  80. package/src/filters/index.ts +1 -0
  81. package/src/filters/multiselect-filter/multiselect-filter.tsx +16 -7
  82. package/src/filters/select-filter/__tests__/object-search.test.ts +32 -0
  83. package/src/filters/select-filter/object-search.ts +25 -0
  84. package/src/filters/select-filter/select-filter.stories.tsx +8 -0
  85. package/src/filters/select-filter/select-filter.tsx +320 -0
  86. package/src/filters/select-filter/value-getter.ts +13 -0
  87. package/src/filters/single-select/single-select-filter.tsx +18 -10
  88. package/src/index.ts +1 -0
@@ -0,0 +1,25 @@
1
+ export const objectSearch = (search: string) => {
2
+ const s = search.toLowerCase().trim();
3
+
4
+ return (item: any): boolean => {
5
+ if (!s) {
6
+ return true;
7
+ }
8
+
9
+ if (item === null) {
10
+ return false;
11
+ }
12
+
13
+ if (typeof item === 'string') {
14
+ return item.toLowerCase().includes(s);
15
+ }
16
+
17
+ if (typeof item === 'object') {
18
+ return Object.values(item).some(value =>
19
+ typeof value === 'string' ? value.toLowerCase().includes(s) : false
20
+ );
21
+ }
22
+
23
+ return false;
24
+ };
25
+ };
@@ -0,0 +1,8 @@
1
+ import { SelectAsyncExample, SelectSyncExample } from '../../demo/filters/select-filter';
2
+
3
+ export default {
4
+ title: 'Table/Filters/Select',
5
+ };
6
+
7
+ export const AsyncSelect = () => <SelectAsyncExample />;
8
+ export const SyncSelect = () => <SelectSyncExample />;
@@ -0,0 +1,320 @@
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 {
14
+ CustomColumnMenuFilterSingleOpts,
15
+ renderCustomColumnMenuFilter,
16
+ } from '../column-menu-filters';
17
+ import { makeObservable, observable, runInAction, toJS } from 'mobx';
18
+ import { observer } from 'mobx-react';
19
+ import { getSimpleValue } from './value-getter';
20
+ import { objectSearch } from './object-search';
21
+
22
+ export type SelectFilterDataFetcher<TO> = (opts: { search?: string }) => Promise<{ data: TO[] }>;
23
+
24
+ export interface SelectFilterSearchOptions<TO = any> {
25
+ placeholder?: string;
26
+ filter(search: string): (item: TO) => boolean | undefined;
27
+ }
28
+
29
+ interface SelectorProps<TO> {
30
+ search?: SelectFilterSearchOptions<TO>;
31
+ selected: TO[];
32
+ data?: TO[];
33
+ dataFetcher?: SelectFilterDataFetcher<TO>;
34
+ itemComponent: FC<SelectorItemProps<TO>>;
35
+ onChange(option: TO, checked: boolean, event: SyntheticEvent<HTMLInputElement>): void;
36
+ valueSelector(item: TO): IdType;
37
+ renderItem(item: TO): ReactNode;
38
+ }
39
+
40
+ interface SelectorItemProps<TO> {
41
+ option: TO;
42
+ checked: boolean;
43
+ onChange(option: TO, checked: boolean, event: SyntheticEvent<HTMLInputElement>): void;
44
+ renderer?(item: TO): ReactNode;
45
+ }
46
+
47
+ @observer
48
+ class SelectorAsync<TO> extends Component<SelectorProps<TO>> {
49
+ @observable shownOptions: TO[] = [];
50
+ @observable search = '';
51
+ @observable error = false;
52
+ @observable loading = false;
53
+
54
+ constructor(props: SelectorProps<TO>) {
55
+ super(props);
56
+ makeObservable(this);
57
+ }
58
+
59
+ componentDidMount() {
60
+ this.searchOptions().catch();
61
+ }
62
+
63
+ handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
64
+ if (event.key === 'Enter') {
65
+ event.stopPropagation();
66
+ }
67
+ };
68
+
69
+ handleSearch = (_0: SyntheticEvent<HTMLInputElement>, data: { value: string }) => {
70
+ runInAction(() => (this.search = data.value));
71
+ this.searchOptions().catch();
72
+ };
73
+
74
+ searchOptions = async () => {
75
+ if (this.props.dataFetcher) {
76
+ runInAction(() => {
77
+ this.loading = true;
78
+ });
79
+ }
80
+
81
+ try {
82
+ const data = await this.getData();
83
+
84
+ runInAction(() => {
85
+ this.shownOptions = data;
86
+ this.error = false;
87
+ this.loading = false;
88
+ });
89
+ } catch {
90
+ runInAction(() => {
91
+ this.error = true;
92
+ this.loading = false;
93
+ });
94
+ }
95
+ };
96
+
97
+ render() {
98
+ const selectedOptions = this.props.selected;
99
+ const selected = new Set(selectedOptions.map(opt => this.props.valueSelector(opt)));
100
+ const ItemComponent = this.props.itemComponent;
101
+
102
+ return (
103
+ <Fragment>
104
+ {!!this.props.search && (
105
+ <Input
106
+ className="m-x-half m-t-half m-b-2"
107
+ placeholder={this.props.search?.placeholder}
108
+ onChange={this.handleSearch}
109
+ onKeyDown={this.handleKeyDown}
110
+ size="xsmall"
111
+ value={this.search}
112
+ />
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, index) => (
118
+ <ItemComponent
119
+ key={this.getItemKey(option, index)}
120
+ option={option}
121
+ checked
122
+ renderer={this.props.renderItem}
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(this.props.valueSelector(opt)))
133
+ .map((option, index) => (
134
+ <ItemComponent
135
+ key={this.getItemKey(option, index)}
136
+ option={option}
137
+ checked={false}
138
+ renderer={this.props.renderItem}
139
+ onChange={this.props.onChange}
140
+ />
141
+ ))
142
+ ) : this.loading ? (
143
+ <BodyText>&nbsp;</BodyText>
144
+ ) : (
145
+ <BodyText subdued>No options match search criteria</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
+ private async getData(): Promise<TO[]> {
159
+ if (this.props.dataFetcher) {
160
+ const { data } = await this.props.dataFetcher({
161
+ search: this.search,
162
+ });
163
+
164
+ return data;
165
+ }
166
+
167
+ let data = this.props.data ?? [];
168
+
169
+ if (this.props.search?.filter) {
170
+ data = data.filter(this.props.search.filter(this.search));
171
+ }
172
+ return data;
173
+ }
174
+
175
+ private getItemKey = (item: TO, index: number) => {
176
+ return `${index}__${this.props.valueSelector(item)}`;
177
+ };
178
+ }
179
+
180
+ const SelectorItemSingle: FC<SelectorItemProps<any>> = ({
181
+ option,
182
+ renderer,
183
+ checked,
184
+ onChange,
185
+ }) => (
186
+ <Radio
187
+ label={renderer?.(option) ?? `${option}`}
188
+ checked={checked}
189
+ onChange={(_, event) => onChange(option, true, event)}
190
+ className="m-b-1"
191
+ />
192
+ );
193
+
194
+ const SelectorItemMultiple: FC<SelectorItemProps<any>> = ({
195
+ option,
196
+ renderer,
197
+ checked,
198
+ onChange,
199
+ }) => (
200
+ <Checkbox
201
+ label={renderer?.(option) ?? `${option}`}
202
+ checked={checked}
203
+ onChange={(_, checked, event) => onChange(option, checked, event)}
204
+ className="m-b-1"
205
+ />
206
+ );
207
+
208
+ export interface SelectFilterOptions<TO> extends CustomColumnMenuFilterSingleOpts {
209
+ /** Can select multiple options in filter */
210
+ multiple?: boolean;
211
+ /** Ability to search options in filter */
212
+ search?: boolean | Partial<SelectFilterSearchOptions>;
213
+ /** Static options to show in filter */
214
+ data?: TO[];
215
+ /** Method to fetch filter options asynchronously */
216
+ dataFetcher?: SelectFilterDataFetcher<TO>;
217
+ /** Search operator passed to table state */
218
+ operator?: ((value: any, options?: TO[]) => boolean) | ((value: any, options?: TO) => boolean);
219
+ /** Select item value (ex id) for complex items */
220
+ valueSelector?(item: TO): IdType;
221
+ /** Select row item value (from table source row field) for complex items */
222
+ rowValueSelector?(item: any): IdType | undefined;
223
+ /** Render option label */
224
+ renderItem?(item: TO): ReactNode;
225
+ }
226
+
227
+ interface TableFilterCellPropsTyped<T = any> extends Omit<TableFilterCellProps, 'value'> {
228
+ value?: T;
229
+ }
230
+
231
+ export function selectColumnMenuFilter<TO>({
232
+ dataFetcher,
233
+ data,
234
+ search,
235
+ multiple,
236
+ valueSelector = getSimpleValue,
237
+ rowValueSelector = getSimpleValue,
238
+ renderItem,
239
+ operator,
240
+ ...opts
241
+ }: SelectFilterOptions<TO>) {
242
+ const renderer = renderItem ?? (item => valueSelector(item));
243
+
244
+ const contains = (value: any, options?: TO[]) =>
245
+ options?.some(opt => valueSelector(opt) === rowValueSelector(value));
246
+
247
+ const equals = (value: any, option?: TO) =>
248
+ option === undefined ? false : valueSelector(option) === rowValueSelector(value);
249
+
250
+ const searchOptions = search
251
+ ? {
252
+ filter: objectSearch,
253
+ ...(typeof search === 'boolean' ? {} : search),
254
+ }
255
+ : undefined;
256
+
257
+ if (multiple) {
258
+ const FilterCell = ({ value, onChange }: TableFilterCellPropsTyped<TO[]>) => {
259
+ const handleChange = (
260
+ option: TO,
261
+ checked: boolean,
262
+ event: SyntheticEvent<HTMLInputElement>
263
+ ) => {
264
+ const val = checked
265
+ ? (value ?? []).concat(option)
266
+ : (value ?? []).filter(opt =>
267
+ valueSelector
268
+ ? valueSelector(opt) !== valueSelector(option)
269
+ : option !== opt
270
+ );
271
+
272
+ onChange({
273
+ value: val.length ? toJS(val) : undefined,
274
+ operator: val.length ? operator ?? contains : '',
275
+ syntheticEvent: event,
276
+ });
277
+ };
278
+
279
+ return (
280
+ <SelectorAsync
281
+ selected={value ?? []}
282
+ itemComponent={SelectorItemMultiple}
283
+ onChange={handleChange}
284
+ search={searchOptions}
285
+ renderItem={renderer}
286
+ dataFetcher={dataFetcher}
287
+ data={data}
288
+ valueSelector={valueSelector}
289
+ />
290
+ );
291
+ };
292
+
293
+ return renderCustomColumnMenuFilter(FilterCell, opts);
294
+ }
295
+
296
+ const FilterCell = ({ value, onChange }: TableFilterCellPropsTyped<TO>) => {
297
+ const handleChange = (option: TO, _: boolean, event: SyntheticEvent<HTMLInputElement>) => {
298
+ onChange({
299
+ value: toJS(option),
300
+ operator: operator ?? equals,
301
+ syntheticEvent: event,
302
+ });
303
+ };
304
+
305
+ return (
306
+ <SelectorAsync
307
+ selected={value ? [value] : []}
308
+ onChange={handleChange}
309
+ itemComponent={SelectorItemSingle}
310
+ search={searchOptions}
311
+ renderItem={renderer}
312
+ dataFetcher={dataFetcher}
313
+ data={data}
314
+ valueSelector={valueSelector}
315
+ />
316
+ );
317
+ };
318
+
319
+ return renderCustomColumnMenuFilter(FilterCell, opts);
320
+ }
@@ -0,0 +1,13 @@
1
+ import { IdType } from '@servicetitan/data-query';
2
+
3
+ export const getSimpleValue = (item: any): IdType => {
4
+ if (item === null) {
5
+ return item;
6
+ }
7
+
8
+ if (typeof item === 'object' || typeof item === 'function') {
9
+ return `${item}`;
10
+ }
11
+
12
+ return item;
13
+ };
@@ -3,7 +3,10 @@ import { Component, SyntheticEvent, ReactNode, FC } from 'react';
3
3
  import { IdType } from '@servicetitan/data-query';
4
4
  import { TableFilterCellProps, Radio } from '@servicetitan/design-system';
5
5
 
6
- import { renderCustomColumnMenuFilter } from '../column-menu-filters';
6
+ import {
7
+ CustomColumnMenuFilterSingleOpts,
8
+ renderCustomColumnMenuFilter,
9
+ } from '../column-menu-filters';
7
10
 
8
11
  interface SelectorProps<TV, TO> {
9
12
  options: TO[];
@@ -33,16 +36,21 @@ class SelectorRadio<TV, TO> extends Component<SelectorProps<TV, TO>> {
33
36
  }
34
37
  }
35
38
 
36
- export interface SingleSelectColumnMenuOptions<TV, TO = TV> {
39
+ export interface SingleSelectColumnMenuOptions<TV, TO = TV>
40
+ extends CustomColumnMenuFilterSingleOpts {
37
41
  options: TO[];
38
42
  valueSelector?: (item: TO) => TV;
39
43
  renderItem?: (item: TO) => ReactNode | string;
40
44
  filterOperator?: (listItem: any, value: TV) => boolean;
41
45
  }
42
46
 
43
- export function singleSelectColumnMenuFilter<TV extends IdType, TO = TV>(
44
- props: SingleSelectColumnMenuOptions<TV, TO>
45
- ) {
47
+ export function singleSelectColumnMenuFilter<TV extends IdType, TO = TV>({
48
+ options,
49
+ valueSelector,
50
+ renderItem,
51
+ filterOperator,
52
+ ...opts
53
+ }: SingleSelectColumnMenuOptions<TV, TO>) {
46
54
  const FilterCell: FC<TableFilterCellProps> = ({ value, onChange }) => {
47
55
  const handleChange = (value: TV | undefined, event: SyntheticEvent<HTMLInputElement>) => {
48
56
  const filter =
@@ -50,7 +58,7 @@ export function singleSelectColumnMenuFilter<TV extends IdType, TO = TV>(
50
58
  ? { value: '', operator: '' }
51
59
  : {
52
60
  value,
53
- operator: props.filterOperator ?? 'equals',
61
+ operator: filterOperator ?? 'equals',
54
62
  };
55
63
 
56
64
  onChange({
@@ -61,14 +69,14 @@ export function singleSelectColumnMenuFilter<TV extends IdType, TO = TV>(
61
69
 
62
70
  return (
63
71
  <SelectorRadio
64
- options={props.options}
72
+ options={options}
65
73
  value={value === '' ? undefined : value}
66
74
  onChange={handleChange}
67
- renderer={props.renderItem}
68
- valueSelector={props.valueSelector ?? (option => option)}
75
+ renderer={renderItem}
76
+ valueSelector={valueSelector ?? (option => option)}
69
77
  />
70
78
  );
71
79
  };
72
80
 
73
- return renderCustomColumnMenuFilter(FilterCell);
81
+ return renderCustomColumnMenuFilter(FilterCell, opts);
74
82
  }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  TableColumnReorderEvent,
16
16
  TableCell,
17
17
  TableHeaderCellProps,
18
+ TableHeaderCell,
18
19
  TableRowProps,
19
20
  TableRowClickEvent,
20
21
  TableDetailRow,