@servicetitan/table 23.4.0 → 24.0.0
Sign up to get free protection for your applications and to get access to all the features.
- package/dist/demo/filters/async-select-filter.d.ts +3 -0
- package/dist/demo/filters/async-select-filter.d.ts.map +1 -0
- package/dist/demo/filters/async-select-filter.js +23 -0
- package/dist/demo/filters/async-select-filter.js.map +1 -0
- package/dist/demo/filters/categories.d.ts +9 -0
- package/dist/demo/filters/categories.d.ts.map +1 -0
- package/dist/demo/filters/categories.js +33 -0
- package/dist/demo/filters/categories.js.map +1 -0
- package/dist/demo/filters/single-select-filter.d.ts +3 -0
- package/dist/demo/filters/single-select-filter.d.ts.map +1 -0
- package/dist/demo/filters/single-select-filter.js +20 -0
- package/dist/demo/filters/single-select-filter.js.map +1 -0
- package/dist/demo/filters/table.store.d.ts +9 -0
- package/dist/demo/filters/table.store.d.ts.map +1 -0
- package/dist/demo/filters/table.store.js +73 -0
- package/dist/demo/filters/table.store.js.map +1 -0
- package/dist/filters/async-select/async-select-filter.d.ts +19 -0
- package/dist/filters/async-select/async-select-filter.d.ts.map +1 -0
- package/dist/filters/async-select/async-select-filter.js +168 -0
- package/dist/filters/async-select/async-select-filter.js.map +1 -0
- package/dist/filters/async-select/async-select-filter.stories.d.ts +7 -0
- package/dist/filters/async-select/async-select-filter.stories.d.ts.map +1 -0
- package/dist/filters/async-select/async-select-filter.stories.js +7 -0
- package/dist/filters/async-select/async-select-filter.stories.js.map +1 -0
- package/dist/filters/index.d.ts +2 -0
- package/dist/filters/index.d.ts.map +1 -1
- package/dist/filters/index.js +2 -0
- package/dist/filters/index.js.map +1 -1
- package/dist/filters/single-select/single-select-filter.d.ts +9 -0
- package/dist/filters/single-select/single-select-filter.d.ts.map +1 -0
- package/dist/filters/single-select/single-select-filter.js +30 -0
- package/dist/filters/single-select/single-select-filter.js.map +1 -0
- package/dist/filters/single-select/single-select-filter.stories.d.ts +7 -0
- package/dist/filters/single-select/single-select-filter.stories.d.ts.map +1 -0
- package/dist/filters/single-select/single-select-filter.stories.js +7 -0
- package/dist/filters/single-select/single-select-filter.stories.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/filters.d.ts +4 -0
- package/dist/utils/filters.d.ts.map +1 -0
- package/dist/utils/filters.js +22 -0
- package/dist/utils/filters.js.map +1 -0
- package/package.json +6 -6
- package/src/demo/filters/async-select-filter.tsx +53 -0
- package/src/demo/filters/categories.tsx +40 -0
- package/src/demo/filters/single-select-filter.tsx +51 -0
- package/src/demo/filters/table.store.ts +45 -0
- package/src/filters/async-select/async-select-filter.stories.tsx +7 -0
- package/src/filters/async-select/async-select-filter.tsx +249 -0
- package/src/filters/index.ts +2 -0
- package/src/filters/single-select/single-select-filter.stories.tsx +7 -0
- package/src/filters/single-select/single-select-filter.tsx +66 -0
- package/src/index.ts +1 -0
- package/src/utils/__tests__/filters.test.ts +31 -0
- 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": "
|
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": "^
|
40
|
+
"@servicetitan/data-query": "^24.0.0",
|
41
41
|
"@servicetitan/design-system": ">=12.4.1",
|
42
|
-
"@servicetitan/form": "^
|
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": "^
|
56
|
+
"@servicetitan/data-query": "^24.0.0",
|
57
57
|
"@servicetitan/design-system": ">=12.4.1",
|
58
|
-
"@servicetitan/form": "^
|
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": "
|
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,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> </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
|
+
}
|
package/src/filters/index.ts
CHANGED
@@ -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,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
@@ -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
|
+
};
|