@tcn/ui-table 2.2.0 → 2.3.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 (217) hide show
  1. package/README.md +1 -1
  2. package/dist/cell.css +1 -0
  3. package/dist/cell.module-WpHnQBVu.js +5 -0
  4. package/dist/cell.module-WpHnQBVu.js.map +1 -0
  5. package/dist/components/cells/data_cell.d.ts +3 -2
  6. package/dist/components/cells/data_cell.d.ts.map +1 -0
  7. package/dist/components/cells/data_cell.js +18 -10
  8. package/dist/components/cells/data_cell.js.map +1 -1
  9. package/dist/components/cells/footer_cell.d.ts +3 -2
  10. package/dist/components/cells/footer_cell.d.ts.map +1 -0
  11. package/dist/components/cells/footer_cell.js +18 -10
  12. package/dist/components/cells/footer_cell.js.map +1 -1
  13. package/dist/components/cells/header_cell.d.ts +3 -2
  14. package/dist/components/cells/header_cell.d.ts.map +1 -0
  15. package/dist/components/cells/header_cell.js +52 -18
  16. package/dist/components/cells/header_cell.js.map +1 -1
  17. package/dist/components/cells/sticky_row_data_cell.d.ts +3 -2
  18. package/dist/components/cells/sticky_row_data_cell.d.ts.map +1 -0
  19. package/dist/components/cells/sticky_row_data_cell.js +26 -11
  20. package/dist/components/cells/sticky_row_data_cell.js.map +1 -1
  21. package/dist/components/cells/sticky_row_fill_cell.d.ts +2 -2
  22. package/dist/components/cells/sticky_row_fill_cell.d.ts.map +1 -0
  23. package/dist/components/cells/sticky_row_fill_cell.js +15 -5
  24. package/dist/components/cells/sticky_row_fill_cell.js.map +1 -1
  25. package/dist/components/global_search.d.ts +2 -2
  26. package/dist/components/global_search.d.ts.map +1 -0
  27. package/dist/components/global_search.js +26 -9
  28. package/dist/components/global_search.js.map +1 -1
  29. package/dist/components/global_search_presenter.d.ts +2 -1
  30. package/dist/components/global_search_presenter.d.ts.map +1 -0
  31. package/dist/components/global_search_presenter.js +20 -18
  32. package/dist/components/global_search_presenter.js.map +1 -1
  33. package/dist/components/table/table.d.ts +3 -2
  34. package/dist/components/table/table.d.ts.map +1 -0
  35. package/dist/components/table/table.js +140 -77
  36. package/dist/components/table/table.js.map +1 -1
  37. package/dist/components/table/table_column.d.ts +1 -1
  38. package/dist/components/table/table_column.d.ts.map +1 -0
  39. package/dist/components/table/table_column.js +6 -5
  40. package/dist/components/table/table_column.js.map +1 -1
  41. package/dist/components/table/table_presenter.d.ts +3 -2
  42. package/dist/components/table/table_presenter.d.ts.map +1 -0
  43. package/dist/components/table/table_presenter.js +45 -62
  44. package/dist/components/table/table_presenter.js.map +1 -1
  45. package/dist/components/table_filter_panel/field_filters/date_field_filter.d.ts +2 -2
  46. package/dist/components/table_filter_panel/field_filters/date_field_filter.d.ts.map +1 -0
  47. package/dist/components/table_filter_panel/field_filters/date_field_filter.js +59 -33
  48. package/dist/components/table_filter_panel/field_filters/date_field_filter.js.map +1 -1
  49. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.d.ts +4 -3
  50. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.d.ts.map +1 -0
  51. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.js +57 -91
  52. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.js.map +1 -1
  53. package/dist/components/table_filter_panel/field_filters/field_filter_props.d.ts +1 -0
  54. package/dist/components/table_filter_panel/field_filters/field_filter_props.d.ts.map +1 -0
  55. package/dist/components/table_filter_panel/field_filters/field_filter_strategy.d.ts +1 -0
  56. package/dist/components/table_filter_panel/field_filters/field_filter_strategy.d.ts.map +1 -0
  57. package/dist/components/table_filter_panel/field_filters/mulit_select_field_filter.d.ts +3 -3
  58. package/dist/components/table_filter_panel/field_filters/mulit_select_field_filter.d.ts.map +1 -0
  59. package/dist/components/table_filter_panel/field_filters/mulit_select_field_filter.js +52 -29
  60. package/dist/components/table_filter_panel/field_filters/mulit_select_field_filter.js.map +1 -1
  61. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.d.ts +3 -2
  62. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.d.ts.map +1 -0
  63. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.js +53 -70
  64. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.js.map +1 -1
  65. package/dist/components/table_filter_panel/field_filters/number_field_filter.d.ts +3 -3
  66. package/dist/components/table_filter_panel/field_filters/number_field_filter.d.ts.map +1 -0
  67. package/dist/components/table_filter_panel/field_filters/number_field_filter.js +47 -23
  68. package/dist/components/table_filter_panel/field_filters/number_field_filter.js.map +1 -1
  69. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.d.ts +5 -4
  70. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.d.ts.map +1 -0
  71. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.js +53 -58
  72. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.js.map +1 -1
  73. package/dist/components/table_filter_panel/field_filters/number_range_field_filter.d.ts +2 -2
  74. package/dist/components/table_filter_panel/field_filters/number_range_field_filter.d.ts.map +1 -0
  75. package/dist/components/table_filter_panel/field_filters/number_range_field_filter.js +61 -31
  76. package/dist/components/table_filter_panel/field_filters/number_range_field_filter.js.map +1 -1
  77. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.d.ts +4 -3
  78. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.d.ts.map +1 -0
  79. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.js +57 -91
  80. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.js.map +1 -1
  81. package/dist/components/table_filter_panel/field_filters/select_field_filter.d.ts +3 -3
  82. package/dist/components/table_filter_panel/field_filters/select_field_filter.d.ts.map +1 -0
  83. package/dist/components/table_filter_panel/field_filters/select_field_filter.js +49 -24
  84. package/dist/components/table_filter_panel/field_filters/select_field_filter.js.map +1 -1
  85. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.d.ts +3 -2
  86. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.d.ts.map +1 -0
  87. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.js +49 -53
  88. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.js.map +1 -1
  89. package/dist/components/table_filter_panel/field_filters/string_field_filter.d.ts +3 -3
  90. package/dist/components/table_filter_panel/field_filters/string_field_filter.d.ts.map +1 -0
  91. package/dist/components/table_filter_panel/field_filters/string_field_filter.js +62 -33
  92. package/dist/components/table_filter_panel/field_filters/string_field_filter.js.map +1 -1
  93. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.d.ts +5 -4
  94. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.d.ts.map +1 -0
  95. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.js +54 -59
  96. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.js.map +1 -1
  97. package/dist/components/table_filter_panel/field_filters/use_field_filter_strategy.d.ts +2 -1
  98. package/dist/components/table_filter_panel/field_filters/use_field_filter_strategy.d.ts.map +1 -0
  99. package/dist/components/table_filter_panel/field_filters/use_field_filter_strategy.js +13 -19
  100. package/dist/components/table_filter_panel/field_filters/use_field_filter_strategy.js.map +1 -1
  101. package/dist/components/table_filter_panel/table_filter_panel.d.ts +5 -4
  102. package/dist/components/table_filter_panel/table_filter_panel.d.ts.map +1 -0
  103. package/dist/components/table_filter_panel/table_filter_panel.js +15 -11
  104. package/dist/components/table_filter_panel/table_filter_panel.js.map +1 -1
  105. package/dist/components/table_filter_panel/table_filter_panel_presenter.d.ts +2 -2
  106. package/dist/components/table_filter_panel/table_filter_panel_presenter.d.ts.map +1 -0
  107. package/dist/components/table_filter_panel/table_filter_panel_presenter.js +45 -62
  108. package/dist/components/table_filter_panel/table_filter_panel_presenter.js.map +1 -1
  109. package/dist/components/table_filter_panel/types.d.ts +1 -0
  110. package/dist/components/table_filter_panel/types.d.ts.map +1 -0
  111. package/dist/components/table_filter_panel/types.js +5 -2
  112. package/dist/components/table_filter_panel/types.js.map +1 -1
  113. package/dist/components/table_pager.d.ts +2 -2
  114. package/dist/components/table_pager.d.ts.map +1 -0
  115. package/dist/components/table_pager.js +22 -20
  116. package/dist/components/table_pager.js.map +1 -1
  117. package/dist/index.d.ts +1 -0
  118. package/dist/index.d.ts.map +1 -0
  119. package/dist/index.js +27 -13
  120. package/dist/index.js.map +1 -1
  121. package/dist/table.css +1 -0
  122. package/dist/table_pager.css +1 -0
  123. package/package.json +61 -61
  124. package/src/__stories__/aip_table.stories.tsx +190 -0
  125. package/src/__stories__/auth_provider.tsx +14 -0
  126. package/src/__stories__/demo.stories.tsx +137 -0
  127. package/src/__stories__/sample_data.ts +1398 -0
  128. package/src/__stories__/table.stories.tsx +423 -0
  129. package/src/__tests__/sanity.test.ts +7 -0
  130. package/src/components/cells/data_cell.tsx +25 -0
  131. package/src/components/cells/footer_cell.tsx +25 -0
  132. package/src/components/cells/header_cell.tsx +77 -0
  133. package/src/components/cells/sticky_row_data_cell.tsx +31 -0
  134. package/src/components/cells/sticky_row_fill_cell.tsx +16 -0
  135. package/src/components/global_search.tsx +33 -0
  136. package/src/components/global_search_presenter.ts +24 -0
  137. package/{dist → src}/components/table/table.module.css +3 -2
  138. package/src/components/table/table.tsx +183 -0
  139. package/src/components/table/table_column.tsx +27 -0
  140. package/src/components/table/table_presenter.test.ts +161 -0
  141. package/src/components/table/table_presenter.ts +103 -0
  142. package/src/components/table_filter_panel/field_filters/date_field_filter.tsx +70 -0
  143. package/src/components/table_filter_panel/field_filters/date_field_filter_presenter.test.ts +583 -0
  144. package/src/components/table_filter_panel/field_filters/date_field_filter_presenter.ts +110 -0
  145. package/src/components/table_filter_panel/field_filters/field_filter_props.ts +5 -0
  146. package/src/components/table_filter_panel/field_filters/field_filter_strategy.ts +14 -0
  147. package/src/components/table_filter_panel/field_filters/mulit_select_field_filter.tsx +68 -0
  148. package/src/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.test.ts +444 -0
  149. package/src/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.ts +90 -0
  150. package/src/components/table_filter_panel/field_filters/number_field_filter.tsx +53 -0
  151. package/src/components/table_filter_panel/field_filters/number_field_filter_presenter.test.ts +431 -0
  152. package/src/components/table_filter_panel/field_filters/number_field_filter_presenter.ts +80 -0
  153. package/src/components/table_filter_panel/field_filters/number_range_field_filter.tsx +68 -0
  154. package/src/components/table_filter_panel/field_filters/number_range_field_filter_presenter.test.ts +582 -0
  155. package/src/components/table_filter_panel/field_filters/number_range_field_filter_presenter.ts +110 -0
  156. package/src/components/table_filter_panel/field_filters/select_field_filter.tsx +57 -0
  157. package/src/components/table_filter_panel/field_filters/select_field_filter_presenter.test.ts +365 -0
  158. package/src/components/table_filter_panel/field_filters/select_field_filter_presenter.ts +74 -0
  159. package/src/components/table_filter_panel/field_filters/string_field_filter.tsx +70 -0
  160. package/src/components/table_filter_panel/field_filters/string_field_filter_presenter.test.ts +296 -0
  161. package/src/components/table_filter_panel/field_filters/string_field_filter_presenter.ts +81 -0
  162. package/src/components/table_filter_panel/field_filters/use_field_filter_strategy.tsx +30 -0
  163. package/src/components/table_filter_panel/table_filter_panel.stories.tsx +46 -0
  164. package/src/components/table_filter_panel/table_filter_panel.tsx +26 -0
  165. package/src/components/table_filter_panel/table_filter_panel_presenter.ts +77 -0
  166. package/src/components/table_filter_panel/types.ts +3 -0
  167. package/src/components/table_pager.tsx +39 -0
  168. package/src/index.ts +16 -0
  169. package/tsconfig.json +36 -0
  170. package/types/file_types.d.ts +54 -0
  171. package/types/react_color.d.ts +61 -0
  172. package/dist/__stories__/aip_table.stories.d.ts +0 -5
  173. package/dist/__stories__/aip_table.stories.js +0 -96
  174. package/dist/__stories__/aip_table.stories.js.map +0 -1
  175. package/dist/__stories__/auth_provider.d.ts +0 -4
  176. package/dist/__stories__/auth_provider.js +0 -10
  177. package/dist/__stories__/auth_provider.js.map +0 -1
  178. package/dist/__stories__/demo.stories.d.ts +0 -6
  179. package/dist/__stories__/demo.stories.js +0 -94
  180. package/dist/__stories__/demo.stories.js.map +0 -1
  181. package/dist/__stories__/sample_data.d.ts +0 -36
  182. package/dist/__stories__/sample_data.js +0 -1385
  183. package/dist/__stories__/sample_data.js.map +0 -1
  184. package/dist/__stories__/table.stories.d.ts +0 -12
  185. package/dist/__stories__/table.stories.js +0 -272
  186. package/dist/__stories__/table.stories.js.map +0 -1
  187. package/dist/components/table/table_presenter.test.d.ts +0 -1
  188. package/dist/components/table/table_presenter.test.js +0 -125
  189. package/dist/components/table/table_presenter.test.js.map +0 -1
  190. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.test.d.ts +0 -1
  191. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.test.js +0 -434
  192. package/dist/components/table_filter_panel/field_filters/date_field_filter_presenter.test.js.map +0 -1
  193. package/dist/components/table_filter_panel/field_filters/field_filter_props.js +0 -2
  194. package/dist/components/table_filter_panel/field_filters/field_filter_props.js.map +0 -1
  195. package/dist/components/table_filter_panel/field_filters/field_filter_strategy.js +0 -2
  196. package/dist/components/table_filter_panel/field_filters/field_filter_strategy.js.map +0 -1
  197. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.test.d.ts +0 -1
  198. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.test.js +0 -332
  199. package/dist/components/table_filter_panel/field_filters/multi_select_field_filter_presenter.test.js.map +0 -1
  200. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.test.d.ts +0 -1
  201. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.test.js +0 -347
  202. package/dist/components/table_filter_panel/field_filters/number_field_filter_presenter.test.js.map +0 -1
  203. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.test.d.ts +0 -1
  204. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.test.js +0 -452
  205. package/dist/components/table_filter_panel/field_filters/number_range_field_filter_presenter.test.js.map +0 -1
  206. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.test.d.ts +0 -1
  207. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.test.js +0 -285
  208. package/dist/components/table_filter_panel/field_filters/select_field_filter_presenter.test.js.map +0 -1
  209. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.test.d.ts +0 -1
  210. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.test.js +0 -232
  211. package/dist/components/table_filter_panel/field_filters/string_field_filter_presenter.test.js.map +0 -1
  212. package/dist/components/table_filter_panel/table_filter_panel.stories.d.ts +0 -6
  213. package/dist/components/table_filter_panel/table_filter_panel.stories.js +0 -25
  214. package/dist/components/table_filter_panel/table_filter_panel.stories.js.map +0 -1
  215. /package/{dist → src}/__stories__/table.module.css +0 -0
  216. /package/{dist → src}/components/cells/cell.module.css +0 -0
  217. /package/{dist → src}/components/table_pager.module.css +0 -0
@@ -0,0 +1,14 @@
1
+ import { Node } from 'clarity-pattern-parser';
2
+
3
+ export interface FieldFilterRegistry {
4
+ registerFieldFilter: (fieldName: string, fieldFilter: FieldFilterStrategy) => void;
5
+ unregisterFieldFilter: (fieldName: string) => void;
6
+ }
7
+
8
+ export interface FieldFilterStrategy {
9
+ getFilterString: () => string;
10
+ onChange: (handler: () => void) => () => void;
11
+ setFieldRegistry: (fieldRegistry: FieldFilterRegistry) => void;
12
+ dispose: () => void;
13
+ setFilterState: (filterAst: Node) => void;
14
+ }
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+
3
+ import { CrossCircleIcon } from '@tcn/icons/cross_circle_icon.js';
4
+ import { useSignalValue } from '@tcn/state';
5
+ import { Button } from '@tcn/ui/actions';
6
+ import { Multiselect, Option } from '@tcn/ui/inputs';
7
+ import { Box, HStack, VStack } from '@tcn/ui/stacks';
8
+ import { Title } from '@tcn/ui/typography';
9
+ import { FieldFilterProps } from './field_filter_props.js';
10
+ import { MultiSelectFieldFilterPresenter } from './multi_select_field_filter_presenter.js';
11
+ import { useFieldFilterStrategy } from './use_field_filter_strategy.js';
12
+
13
+ export type MulitSelectFieldFilterProps = FieldFilterProps & {
14
+ options: { label: string; value: string | boolean | number }[];
15
+ };
16
+
17
+ export function MulitSelectFieldFilter({
18
+ fieldName,
19
+ label,
20
+ options,
21
+ }: MulitSelectFieldFilterProps) {
22
+ const presenter = useFieldFilterStrategy(MultiSelectFieldFilterPresenter, fieldName);
23
+
24
+ const values = useSignalValue(presenter.broadcasts.value);
25
+ const selectedLabels =
26
+ values
27
+ ?.map(value => options.find(option => option.value === value)?.label ?? '')
28
+ .filter(Boolean) ?? [];
29
+
30
+ return (
31
+ <VStack gap="4px">
32
+ <Box width="flex">
33
+ <Title size="md">{label}</Title>
34
+ </Box>
35
+ <HStack>
36
+ <Box width="flex">
37
+ <Multiselect
38
+ value={selectedLabels}
39
+ onChange={selectedLabels => {
40
+ const realValues = selectedLabels
41
+ .map(label => options.find(option => option.label === label)?.value)
42
+ .filter(value => value !== undefined) as (string | boolean | number)[];
43
+
44
+ presenter.setValue(realValues.length > 0 ? realValues : null);
45
+ }}
46
+ >
47
+ {options.map(option => (
48
+ <Option
49
+ key={option.value.toString()}
50
+ value={option.label}
51
+ label={option.label}
52
+ >
53
+ {option.label}
54
+ </Option>
55
+ ))}
56
+ </Multiselect>
57
+ </Box>
58
+ <Button
59
+ onClick={() => presenter.setValue(null)}
60
+ hierarchy="tertiary"
61
+ disabled={values == null || values.length === 0}
62
+ >
63
+ <CrossCircleIcon />
64
+ </Button>
65
+ </HStack>
66
+ </VStack>
67
+ );
68
+ }
@@ -0,0 +1,444 @@
1
+ import { MultiSelectFieldFilterPresenter } from './multi_select_field_filter_presenter.js';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ describe('MultiSelectFieldFilterPresenter', () => {
5
+ let presenter: MultiSelectFieldFilterPresenter;
6
+ let mockFieldRegistry: any;
7
+
8
+ beforeEach(() => {
9
+ presenter = new MultiSelectFieldFilterPresenter('test_field');
10
+ mockFieldRegistry = {
11
+ registerFieldFilter: vi.fn(),
12
+ unregisterFieldFilter: vi.fn(),
13
+ };
14
+ });
15
+
16
+ describe('constructor', () => {
17
+ it('should initialize with the correct field name', () => {
18
+ const presenter = new MultiSelectFieldFilterPresenter('custom_field');
19
+ expect(presenter.getFilterString()).toBe('');
20
+ });
21
+
22
+ it('should initialize with default values', () => {
23
+ expect(presenter.getFilterString()).toBe('');
24
+ });
25
+ });
26
+
27
+ describe('setValue', () => {
28
+ it('should set the value when setValue is called with an array of strings', () => {
29
+ presenter.setValue(['value1', 'value2']);
30
+ expect(presenter.getFilterString()).toBe(
31
+ '(test_field = "value1" OR test_field = "value2")'
32
+ );
33
+ });
34
+
35
+ it('should set the value when setValue is called with an array of numbers', () => {
36
+ presenter.setValue([1, 2, 3]);
37
+ expect(presenter.getFilterString()).toBe(
38
+ '(test_field = 1 OR test_field = 2 OR test_field = 3)'
39
+ );
40
+ });
41
+
42
+ it('should set the value when setValue is called with an array of booleans', () => {
43
+ presenter.setValue([true, false]);
44
+ expect(presenter.getFilterString()).toBe(
45
+ '(test_field = true OR test_field = false)'
46
+ );
47
+ });
48
+
49
+ it('should set the value when setValue is called with mixed types', () => {
50
+ presenter.setValue(['string', 123, true]);
51
+ expect(presenter.getFilterString()).toBe(
52
+ '(test_field = "string" OR test_field = 123 OR test_field = true)'
53
+ );
54
+ });
55
+
56
+ it('should set the value when setValue is called with null', () => {
57
+ presenter.setValue(['value1', 'value2']);
58
+ presenter.setValue(null);
59
+ expect(presenter.getFilterString()).toBe('');
60
+ });
61
+
62
+ it('should set the value when setValue is called with empty array', () => {
63
+ presenter.setValue([]);
64
+ expect(presenter.getFilterString()).toBe('');
65
+ });
66
+
67
+ it('should trigger onChange handlers when value is set', () => {
68
+ const onChangeHandler = vi.fn();
69
+ presenter.onChange(onChangeHandler);
70
+
71
+ presenter.setValue(['value1', 'value2']);
72
+
73
+ expect(onChangeHandler).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it('should trigger onChange handlers when value is set to null', () => {
77
+ const onChangeHandler = vi.fn();
78
+ presenter.onChange(onChangeHandler);
79
+
80
+ presenter.setValue(['value1']);
81
+ presenter.setValue(null);
82
+
83
+ expect(onChangeHandler).toHaveBeenCalledTimes(2);
84
+ });
85
+ });
86
+
87
+ describe('getFilterString', () => {
88
+ it('should return empty string when value is null', () => {
89
+ expect(presenter.getFilterString()).toBe('');
90
+ });
91
+
92
+ it('should return empty string when value is set to null', () => {
93
+ presenter.setValue(['value1', 'value2']);
94
+ presenter.setValue(null);
95
+ expect(presenter.getFilterString()).toBe('');
96
+ });
97
+
98
+ it('should return empty string when value is empty array', () => {
99
+ presenter.setValue([]);
100
+ expect(presenter.getFilterString()).toBe('');
101
+ });
102
+
103
+ it('should return single filter without parentheses for one value', () => {
104
+ presenter.setValue(['single_value']);
105
+ expect(presenter.getFilterString()).toBe('test_field = "single_value"');
106
+ });
107
+
108
+ it('should return multiple filters with OR and parentheses for multiple values', () => {
109
+ presenter.setValue(['value1', 'value2']);
110
+ expect(presenter.getFilterString()).toBe(
111
+ '(test_field = "value1" OR test_field = "value2")'
112
+ );
113
+ });
114
+
115
+ it('should handle special characters in string values', () => {
116
+ presenter.setValue(['value"with"quotes', 'normal_value']);
117
+ expect(presenter.getFilterString()).toBe(
118
+ '(test_field = "value"with"quotes" OR test_field = "normal_value")'
119
+ );
120
+ });
121
+
122
+ it('should handle numeric values without quotes', () => {
123
+ presenter.setValue([1, 2, 3]);
124
+ expect(presenter.getFilterString()).toBe(
125
+ '(test_field = 1 OR test_field = 2 OR test_field = 3)'
126
+ );
127
+ });
128
+
129
+ it('should handle boolean values without quotes', () => {
130
+ presenter.setValue([true, false]);
131
+ expect(presenter.getFilterString()).toBe(
132
+ '(test_field = true OR test_field = false)'
133
+ );
134
+ });
135
+
136
+ it('should handle mixed value types correctly', () => {
137
+ presenter.setValue(['string', 123, true, false]);
138
+ expect(presenter.getFilterString()).toBe(
139
+ '(test_field = "string" OR test_field = 123 OR test_field = true OR test_field = false)'
140
+ );
141
+ });
142
+ });
143
+
144
+ describe('onChange', () => {
145
+ it('should register and return unsubscribe function', () => {
146
+ const handler = vi.fn();
147
+ const unsubscribe = presenter.onChange(handler);
148
+
149
+ expect(typeof unsubscribe).toBe('function');
150
+ });
151
+
152
+ it('should call registered handlers when value changes', () => {
153
+ const handler1 = vi.fn();
154
+ const handler2 = vi.fn();
155
+
156
+ presenter.onChange(handler1);
157
+ presenter.onChange(handler2);
158
+
159
+ presenter.setValue(['value1']);
160
+
161
+ expect(handler1).toHaveBeenCalledTimes(1);
162
+ expect(handler2).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('should not call unsubscribed handlers', () => {
166
+ const handler1 = vi.fn();
167
+ const handler2 = vi.fn();
168
+
169
+ const unsubscribe1 = presenter.onChange(handler1);
170
+ presenter.onChange(handler2);
171
+
172
+ unsubscribe1();
173
+ presenter.setValue(['value1']);
174
+
175
+ expect(handler1).not.toHaveBeenCalled();
176
+ expect(handler2).toHaveBeenCalledTimes(1);
177
+ });
178
+
179
+ it('should handle multiple subscriptions and unsubscriptions', () => {
180
+ const handler1 = vi.fn();
181
+ const handler2 = vi.fn();
182
+ const handler3 = vi.fn();
183
+
184
+ const unsubscribe1 = presenter.onChange(handler1);
185
+ const unsubscribe2 = presenter.onChange(handler2);
186
+ const unsubscribe3 = presenter.onChange(handler3);
187
+
188
+ unsubscribe2();
189
+ presenter.setValue(['value1']);
190
+
191
+ expect(handler1).toHaveBeenCalledTimes(1);
192
+ expect(handler2).not.toHaveBeenCalled();
193
+ expect(handler3).toHaveBeenCalledTimes(1);
194
+
195
+ unsubscribe1();
196
+ unsubscribe3();
197
+ presenter.setValue(['value2']);
198
+
199
+ expect(handler1).toHaveBeenCalledTimes(1);
200
+ expect(handler2).not.toHaveBeenCalled();
201
+ expect(handler3).toHaveBeenCalledTimes(1);
202
+ });
203
+ });
204
+
205
+ describe('setFieldRegistry', () => {
206
+ it('should register the field filter with the registry', () => {
207
+ presenter.setFieldRegistry(mockFieldRegistry);
208
+
209
+ expect(mockFieldRegistry.registerFieldFilter).toHaveBeenCalledWith(
210
+ 'test_field',
211
+ presenter
212
+ );
213
+ });
214
+
215
+ it('should store the field registry reference', () => {
216
+ presenter.setFieldRegistry(mockFieldRegistry);
217
+ // This is tested indirectly through the dispose method
218
+ presenter.dispose();
219
+ expect(mockFieldRegistry.unregisterFieldFilter).toHaveBeenCalledWith('test_field');
220
+ });
221
+ });
222
+
223
+ describe('setFilterState', () => {
224
+ it('should parse single string value from filter AST', () => {
225
+ const mockNode = {
226
+ name: 'plain-field',
227
+ value: 'test_field',
228
+ parent: {
229
+ name: 'infix-expression',
230
+ children: [null, null, { name: 'double-quote-str', value: '"test_value"' }],
231
+ },
232
+ };
233
+
234
+ const mockAst = {
235
+ findAll: vi.fn().mockReturnValue([mockNode]),
236
+ };
237
+
238
+ presenter.setFilterState(mockAst as any);
239
+
240
+ expect(presenter.getFilterString()).toBe('test_field = "test_value"');
241
+ });
242
+
243
+ it('should parse multiple string values from filter AST', () => {
244
+ const mockNode1 = {
245
+ name: 'plain-field',
246
+ value: 'test_field',
247
+ parent: {
248
+ name: 'infix-expression',
249
+ children: [null, null, { name: 'double-quote-str', value: '"value1"' }],
250
+ },
251
+ };
252
+
253
+ const mockNode2 = {
254
+ name: 'plain-field',
255
+ value: 'test_field',
256
+ parent: {
257
+ name: 'infix-expression',
258
+ children: [null, null, { name: 'single-quote-string', value: "'value2'" }],
259
+ },
260
+ };
261
+
262
+ const mockAst = {
263
+ findAll: vi.fn().mockReturnValue([mockNode1, mockNode2]),
264
+ };
265
+
266
+ presenter.setFilterState(mockAst as any);
267
+
268
+ expect(presenter.getFilterString()).toBe(
269
+ '(test_field = "value1" OR test_field = "value2")'
270
+ );
271
+ });
272
+
273
+ it('should parse numeric values from filter AST', () => {
274
+ const mockNode = {
275
+ name: 'plain-field',
276
+ value: 'test_field',
277
+ parent: {
278
+ name: 'infix-expression',
279
+ children: [null, null, { name: 'number', value: '123' }],
280
+ },
281
+ };
282
+
283
+ const mockAst = {
284
+ findAll: vi.fn().mockReturnValue([mockNode]),
285
+ };
286
+
287
+ presenter.setFilterState(mockAst as any);
288
+
289
+ expect(presenter.getFilterString()).toBe('test_field = 123');
290
+ });
291
+
292
+ it('should parse boolean values from filter AST', () => {
293
+ const mockNode = {
294
+ name: 'plain-field',
295
+ value: 'test_field',
296
+ parent: {
297
+ name: 'infix-expression',
298
+ children: [null, null, { name: 'boolean', value: 'true' }],
299
+ },
300
+ };
301
+
302
+ const mockAst = {
303
+ findAll: vi.fn().mockReturnValue([mockNode]),
304
+ };
305
+
306
+ presenter.setFilterState(mockAst as any);
307
+
308
+ expect(presenter.getFilterString()).toBe('test_field = 1');
309
+ });
310
+
311
+ it('should handle mixed value types from filter AST', () => {
312
+ const mockNode1 = {
313
+ name: 'plain-field',
314
+ value: 'test_field',
315
+ parent: {
316
+ name: 'infix-expression',
317
+ children: [null, null, { name: 'double-quote-str', value: '"string"' }],
318
+ },
319
+ };
320
+
321
+ const mockNode2 = {
322
+ name: 'plain-field',
323
+ value: 'test_field',
324
+ parent: {
325
+ name: 'infix-expression',
326
+ children: [null, null, { name: 'number', value: '123' }],
327
+ },
328
+ };
329
+
330
+ const mockNode3 = {
331
+ name: 'plain-field',
332
+ value: 'test_field',
333
+ parent: {
334
+ name: 'infix-expression',
335
+ children: [null, null, { name: 'boolean', value: 'false' }],
336
+ },
337
+ };
338
+
339
+ const mockAst = {
340
+ findAll: vi.fn().mockReturnValue([mockNode1, mockNode2, mockNode3]),
341
+ };
342
+
343
+ presenter.setFilterState(mockAst as any);
344
+
345
+ expect(presenter.getFilterString()).toBe(
346
+ '(test_field = "string" OR test_field = 123 OR test_field = 0)'
347
+ );
348
+ });
349
+
350
+ it('should handle no matching field nodes', () => {
351
+ const mockAst = {
352
+ findAll: vi.fn().mockReturnValue([]),
353
+ };
354
+
355
+ presenter.setFilterState(mockAst as any);
356
+
357
+ expect(presenter.getFilterString()).toBe('');
358
+ });
359
+
360
+ it('should handle field nodes without proper parent structure', () => {
361
+ const mockNode = {
362
+ name: 'plain-field',
363
+ value: 'test_field',
364
+ parent: null,
365
+ };
366
+
367
+ const mockAst = {
368
+ findAll: vi.fn().mockReturnValue([mockNode]),
369
+ };
370
+
371
+ presenter.setFilterState(mockAst as any);
372
+
373
+ expect(presenter.getFilterString()).toBe('');
374
+ });
375
+
376
+ it('should handle field nodes with non-infix-expression parent', () => {
377
+ const mockNode = {
378
+ name: 'plain-field',
379
+ value: 'test_field',
380
+ parent: {
381
+ name: 'other-expression',
382
+ children: [null, null, { name: 'double-quote-str', value: '"test_value"' }],
383
+ },
384
+ };
385
+
386
+ const mockAst = {
387
+ findAll: vi.fn().mockReturnValue([mockNode]),
388
+ };
389
+
390
+ presenter.setFilterState(mockAst as any);
391
+
392
+ expect(presenter.getFilterString()).toBe('');
393
+ });
394
+
395
+ it('should handle missing value nodes', () => {
396
+ const mockNode = {
397
+ name: 'plain-field',
398
+ value: 'test_field',
399
+ parent: {
400
+ name: 'infix-expression',
401
+ children: [null, null, null],
402
+ },
403
+ };
404
+
405
+ const mockAst = {
406
+ findAll: vi.fn().mockReturnValue([mockNode]),
407
+ };
408
+
409
+ presenter.setFilterState(mockAst as any);
410
+
411
+ expect(presenter.getFilterString()).toBe('');
412
+ });
413
+ });
414
+
415
+ describe('dispose', () => {
416
+ it('should unregister field filter from registry', () => {
417
+ presenter.setFieldRegistry(mockFieldRegistry);
418
+ presenter.dispose();
419
+
420
+ expect(mockFieldRegistry.unregisterFieldFilter).toHaveBeenCalledWith('test_field');
421
+ });
422
+
423
+ it('should clear onChange handlers', () => {
424
+ const handler = vi.fn();
425
+ presenter.onChange(handler);
426
+ presenter.dispose();
427
+ presenter.setValue(['value1']);
428
+
429
+ expect(handler).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it('should handle disposal when no registry is set', () => {
433
+ expect(() => presenter.dispose()).not.toThrow();
434
+ });
435
+ });
436
+
437
+ describe('broadcasts', () => {
438
+ it('should provide value broadcast', () => {
439
+ expect(presenter.broadcasts).toHaveProperty('value');
440
+ expect(typeof presenter.broadcasts.value).toBe('object');
441
+ expect(typeof presenter.broadcasts.value.subscribe).toBe('function');
442
+ });
443
+ });
444
+ });
@@ -0,0 +1,90 @@
1
+ import { Signal } from '@tcn/state';
2
+ import { Node } from 'clarity-pattern-parser';
3
+ import { FieldFilterRegistry, FieldFilterStrategy } from './field_filter_strategy.js';
4
+
5
+ export class MultiSelectFieldFilterPresenter implements FieldFilterStrategy {
6
+ private _fieldName: string;
7
+ private _value = new Signal<(string | boolean | number)[] | null>(null);
8
+ private _fieldFilterRegistry: FieldFilterRegistry | null = null;
9
+ private _onChangeHandlers = new Set<() => void>();
10
+
11
+ private _broadcasts = {
12
+ value: this._value.broadcast,
13
+ };
14
+
15
+ constructor(fieldName: string) {
16
+ this._fieldName = fieldName;
17
+ }
18
+
19
+ onChange(handler: () => void) {
20
+ this._onChangeHandlers.add(handler);
21
+ return () => {
22
+ this._onChangeHandlers.delete(handler);
23
+ };
24
+ }
25
+
26
+ setValue(value: (string | boolean | number)[] | null) {
27
+ this._value.set(value);
28
+ this._onChangeHandlers.forEach(handler => handler());
29
+ }
30
+
31
+ get broadcasts() {
32
+ return this._broadcasts;
33
+ }
34
+
35
+ setFieldRegistry(fieldRegistry: FieldFilterRegistry) {
36
+ this._fieldFilterRegistry = fieldRegistry;
37
+ fieldRegistry.registerFieldFilter(this._fieldName, this);
38
+ }
39
+
40
+ getFilterString() {
41
+ const values = this._value.get();
42
+ if (values == null) {
43
+ return '';
44
+ }
45
+ const wrappedValues = values.map(value =>
46
+ typeof value === 'string' ? `"${value}"` : value
47
+ );
48
+ const filterItems = wrappedValues.map(value => `${this._fieldName} = ${value}`);
49
+ const filterString = filterItems.join(' OR ');
50
+ return filterItems.length > 1 ? `(${filterString})` : filterString;
51
+ }
52
+
53
+ setFilterState(filterAst: Node) {
54
+ const fieldNodes = filterAst
55
+ .findAll((n: Node) => n.name === 'plain-field' && n.value === this._fieldName)
56
+ .filter((node: Node) => node.parent?.name === 'infix-expression');
57
+
58
+ if (fieldNodes.length === 0) {
59
+ return;
60
+ }
61
+
62
+ const valueNodes = fieldNodes
63
+ .map((node: Node) => node.parent?.children[2])
64
+ .filter((node: Node | undefined): node is Node => node != null);
65
+
66
+ const values = valueNodes
67
+ .map((node: Node) => {
68
+ return node.name === 'double-quote-str' || node.name === 'single-quote-string'
69
+ ? node.value.slice(1, -1)
70
+ : node.value;
71
+ })
72
+ .map((value: string) =>
73
+ value === 'true' ? true : value === 'false' ? false : value
74
+ )
75
+ .map((value: string | boolean) => {
76
+ const numValue = Number(value);
77
+ if (isNaN(numValue)) {
78
+ return value;
79
+ }
80
+ return numValue;
81
+ });
82
+
83
+ this._value.set(values);
84
+ }
85
+
86
+ dispose() {
87
+ this._fieldFilterRegistry?.unregisterFieldFilter(this._fieldName);
88
+ this._onChangeHandlers.clear();
89
+ }
90
+ }
@@ -0,0 +1,53 @@
1
+ import { CrossCircleIcon } from '@tcn/icons/cross_circle_icon.js';
2
+ import { useSignalValue } from '@tcn/state';
3
+ import { Button } from '@tcn/ui/actions';
4
+ import { Input, Option, Select } from '@tcn/ui/inputs';
5
+ import { Box, HStack, VStack } from '@tcn/ui/stacks';
6
+ import { Title } from '@tcn/ui/typography';
7
+ import React from 'react';
8
+ import { FieldFilterProps } from './field_filter_props.js';
9
+ import { NumberFieldFilterPresenter } from './number_field_filter_presenter.js';
10
+ import { useFieldFilterStrategy } from './use_field_filter_strategy.js';
11
+ import { ComparisonOperator } from '../types.js';
12
+
13
+ const operators: ComparisonOperator[] = ['>=', '>', '<=', '<', '=', '!='];
14
+
15
+ export function NumberFieldFilter({ fieldName, label }: FieldFilterProps) {
16
+ const presenter = useFieldFilterStrategy(NumberFieldFilterPresenter, fieldName);
17
+
18
+ const value = useSignalValue(presenter.broadcasts.value);
19
+ const operator = useSignalValue(presenter.broadcasts.operator);
20
+
21
+ return (
22
+ <VStack gap="4px">
23
+ <Box width="flex">
24
+ <Title size="md">{label}</Title>
25
+ </Box>
26
+ <HStack width="flex">
27
+ <Select
28
+ value={operator}
29
+ onChange={value => presenter.setOperator(value as ComparisonOperator)}
30
+ width="55px"
31
+ >
32
+ {operators.map(operator => (
33
+ <Option key={operator} value={operator} label={operator}>
34
+ {operator}
35
+ </Option>
36
+ ))}
37
+ </Select>
38
+ <Input
39
+ type="number"
40
+ value={String(value ?? '')}
41
+ onChange={value => presenter.setValue(Number(value))}
42
+ />
43
+ <Button
44
+ onClick={() => presenter.setValue(null)}
45
+ hierarchy="tertiary"
46
+ disabled={value == null}
47
+ >
48
+ <CrossCircleIcon />
49
+ </Button>
50
+ </HStack>
51
+ </VStack>
52
+ );
53
+ }