@proteos/ui 0.31.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.
- package/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { defaultValueFor, operatorsForType } from './operators'
|
|
2
|
+
import type { FilterElement, FilterField, FilterGroup, LogicalOperator } from './types'
|
|
3
|
+
|
|
4
|
+
/* =========================================================================
|
|
5
|
+
group-mutations — pure immutable helpers that produce a new `FilterGroup`
|
|
6
|
+
from the previous one + an action. The recursive popover panel composes
|
|
7
|
+
these instead of inlining `{ ...g, elements: g.elements.map(...) }`
|
|
8
|
+
spaghetti.
|
|
9
|
+
|
|
10
|
+
Invariants:
|
|
11
|
+
- Inputs are never mutated.
|
|
12
|
+
- Index parameters refer to position within the relevant sub-array
|
|
13
|
+
(`elements` for leaves, `groups` for sub-groups). The two arrays are
|
|
14
|
+
addressed independently — the renderer treats them as ordered children
|
|
15
|
+
by concatenation (leaves first, then groups).
|
|
16
|
+
========================================================================= */
|
|
17
|
+
|
|
18
|
+
export function addLeaf(group: FilterGroup, field: FilterField): FilterGroup {
|
|
19
|
+
const ops = operatorsForType(field.type)
|
|
20
|
+
const op = ops[0]
|
|
21
|
+
if (!op) return group
|
|
22
|
+
const next: FilterElement = {
|
|
23
|
+
field: field.path,
|
|
24
|
+
operator: op.id,
|
|
25
|
+
value: defaultValueFor(field.type, op.arity),
|
|
26
|
+
}
|
|
27
|
+
return { ...group, elements: [...(group.elements ?? []), next] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addPendingLeaf(group: FilterGroup): FilterGroup {
|
|
31
|
+
const next: FilterElement = { field: '', operator: 'eq', value: '' }
|
|
32
|
+
return { ...group, elements: [...(group.elements ?? []), next] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addSubgroup(
|
|
36
|
+
group: FilterGroup,
|
|
37
|
+
joiner: LogicalOperator = oppositeJoiner(group.logicalOperator),
|
|
38
|
+
): FilterGroup {
|
|
39
|
+
const sub: FilterGroup = { logicalOperator: joiner, elements: [], groups: [] }
|
|
40
|
+
return { ...group, groups: [...(group.groups ?? []), sub] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function removeLeaf(group: FilterGroup, index: number): FilterGroup {
|
|
44
|
+
const elements = group.elements ?? []
|
|
45
|
+
return { ...group, elements: elements.filter((_, i) => i !== index) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function removeSubgroup(group: FilterGroup, index: number): FilterGroup {
|
|
49
|
+
const groups = group.groups ?? []
|
|
50
|
+
return { ...group, groups: groups.filter((_, i) => i !== index) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateLeaf(
|
|
54
|
+
group: FilterGroup,
|
|
55
|
+
index: number,
|
|
56
|
+
patch: Partial<FilterElement> | FilterElement,
|
|
57
|
+
): FilterGroup {
|
|
58
|
+
const elements = group.elements ?? []
|
|
59
|
+
return {
|
|
60
|
+
...group,
|
|
61
|
+
elements: elements.map((el, i) => (i === index ? { ...el, ...patch } : el)),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function replaceLeaf(
|
|
66
|
+
group: FilterGroup,
|
|
67
|
+
index: number,
|
|
68
|
+
next: FilterElement,
|
|
69
|
+
): FilterGroup {
|
|
70
|
+
const elements = group.elements ?? []
|
|
71
|
+
return { ...group, elements: elements.map((el, i) => (i === index ? next : el)) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function updateSubgroup(
|
|
75
|
+
group: FilterGroup,
|
|
76
|
+
index: number,
|
|
77
|
+
next: FilterGroup,
|
|
78
|
+
): FilterGroup {
|
|
79
|
+
const groups = group.groups ?? []
|
|
80
|
+
return { ...group, groups: groups.map((g, i) => (i === index ? next : g)) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Wrap an existing leaf in a new sub-group with the opposite joiner.
|
|
84
|
+
Leaves the rest of the group untouched. The leaf is moved into the new
|
|
85
|
+
sub-group's `elements`. Used by the "wrap in group" hover action. */
|
|
86
|
+
export function wrapLeafInGroup(group: FilterGroup, index: number): FilterGroup {
|
|
87
|
+
const elements = group.elements ?? []
|
|
88
|
+
const leaf = elements[index]
|
|
89
|
+
if (!leaf) return group
|
|
90
|
+
const sub: FilterGroup = {
|
|
91
|
+
logicalOperator: oppositeJoiner(group.logicalOperator),
|
|
92
|
+
elements: [leaf],
|
|
93
|
+
groups: [],
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...group,
|
|
97
|
+
elements: elements.filter((_, i) => i !== index),
|
|
98
|
+
groups: [...(group.groups ?? []), sub],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function setJoiner(group: FilterGroup, joiner: LogicalOperator): FilterGroup {
|
|
103
|
+
return { ...group, logicalOperator: joiner }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Recursively count leaf filters in a group. Used by the chip pill badge
|
|
107
|
+
and for filter-button counts when nested groups are present. */
|
|
108
|
+
export function countLeaves(group: FilterGroup): number {
|
|
109
|
+
const direct = (group.elements ?? []).length
|
|
110
|
+
const nested = (group.groups ?? []).reduce((n, g) => n + countLeaves(g), 0)
|
|
111
|
+
return direct + nested
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function oppositeJoiner(j: LogicalOperator): LogicalOperator {
|
|
115
|
+
return j === 'and' ? 'or' : 'and'
|
|
116
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export { AttributePicker, type AttributePickerProps } from './attribute-picker'
|
|
2
|
+
export {
|
|
3
|
+
ChipValue,
|
|
4
|
+
type ChipValueItemRenderer,
|
|
5
|
+
type ChipValueProps,
|
|
6
|
+
formatValue,
|
|
7
|
+
RenderChipItemProvider,
|
|
8
|
+
useRenderChipItem,
|
|
9
|
+
} from './chip-value'
|
|
10
|
+
export {
|
|
11
|
+
FilterRow,
|
|
12
|
+
type FilterRowProps,
|
|
13
|
+
rebaseRowOnFieldChange,
|
|
14
|
+
rebaseRowOnOperatorChange,
|
|
15
|
+
} from './filter-row'
|
|
16
|
+
export {
|
|
17
|
+
addLeaf as addFilterLeaf,
|
|
18
|
+
addPendingLeaf,
|
|
19
|
+
addSubgroup,
|
|
20
|
+
countLeaves,
|
|
21
|
+
removeLeaf,
|
|
22
|
+
removeSubgroup,
|
|
23
|
+
replaceLeaf,
|
|
24
|
+
setJoiner,
|
|
25
|
+
updateLeaf,
|
|
26
|
+
updateSubgroup,
|
|
27
|
+
wrapLeafInGroup,
|
|
28
|
+
} from './group-mutations'
|
|
29
|
+
export {
|
|
30
|
+
FilterValueInput,
|
|
31
|
+
type FilterValueInputProps,
|
|
32
|
+
type FilterValueInputRenderer,
|
|
33
|
+
RenderValueInputProvider,
|
|
34
|
+
useRenderValueInput,
|
|
35
|
+
} from './filter-value-input'
|
|
36
|
+
export { OperatorPicker, type OperatorPickerProps } from './operator-picker'
|
|
37
|
+
export { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
|
|
38
|
+
export {
|
|
39
|
+
RecordFilterBuilderPanel,
|
|
40
|
+
type RecordFilterBuilderPanelProps,
|
|
41
|
+
} from './record-filter-builder-panel'
|
|
42
|
+
export { RecordFilterEditor, type RecordFilterEditorProps } from './record-filter-editor'
|
|
43
|
+
export { RecordFilterButton, type RecordFilterButtonProps } from './record-filter-button'
|
|
44
|
+
export { RecordFilterChips, type RecordFilterChipsProps } from './record-filter-chips'
|
|
45
|
+
export { useShowDetails } from './show-details-store'
|
|
46
|
+
export type {
|
|
47
|
+
ComparisonOperator,
|
|
48
|
+
FilterElement,
|
|
49
|
+
FilterField,
|
|
50
|
+
FilterFieldOption,
|
|
51
|
+
FilterFieldRelation,
|
|
52
|
+
FilterFieldType,
|
|
53
|
+
FilterGroup,
|
|
54
|
+
FilterValue,
|
|
55
|
+
LogicalOperator,
|
|
56
|
+
OperatorArity,
|
|
57
|
+
OperatorDescriptor,
|
|
58
|
+
} from './types'
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn'
|
|
2
|
+
import { SegmentedControl } from '../segmented-control/segmented-control'
|
|
3
|
+
import { GroupBody } from './group-body'
|
|
4
|
+
import type { FilterField, FilterGroup, LogicalOperator } from './types'
|
|
5
|
+
|
|
6
|
+
/* =========================================================================
|
|
7
|
+
NestedGroup — non-root group rendered as a bordered, indented panel.
|
|
8
|
+
Layout:
|
|
9
|
+
|
|
10
|
+
[WHERE/AND/OR ] [ ┌─ Group · AND/OR ───────────── × ┐ ]
|
|
11
|
+
[ FilterRow ]
|
|
12
|
+
[ FilterRow ]
|
|
13
|
+
[ + Add filter + Add group ]
|
|
14
|
+
[ └────────────────────────────────┘ ]
|
|
15
|
+
|
|
16
|
+
The left gutter aligns with sibling FilterRows so the joiner ("AND" /
|
|
17
|
+
"OR" or the leading "WHERE") sits in the same column. The bordered panel
|
|
18
|
+
carries the group's own AND/OR header + remove button; its body recurses
|
|
19
|
+
into another `GroupBody`.
|
|
20
|
+
========================================================================= */
|
|
21
|
+
|
|
22
|
+
export type NestedGroupProps = {
|
|
23
|
+
/** Sibling index inside the parent group's combined children list (leaves
|
|
24
|
+
* first, groups last). Drives the joiner label. */
|
|
25
|
+
index: number
|
|
26
|
+
/** Parent group's logical operator — that's what joins this sub-group to
|
|
27
|
+
* its siblings. */
|
|
28
|
+
joiner: LogicalOperator
|
|
29
|
+
group: FilterGroup
|
|
30
|
+
fields: ReadonlyArray<FilterField>
|
|
31
|
+
fieldsByPath: Record<string, FilterField>
|
|
32
|
+
onChange: (next: FilterGroup) => void
|
|
33
|
+
onRemove: () => void
|
|
34
|
+
showFieldIcons: boolean
|
|
35
|
+
showJoinerToggle: boolean
|
|
36
|
+
isComplexEnabled: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function NestedGroup({
|
|
40
|
+
index,
|
|
41
|
+
joiner,
|
|
42
|
+
group,
|
|
43
|
+
fields,
|
|
44
|
+
fieldsByPath,
|
|
45
|
+
onChange,
|
|
46
|
+
onRemove,
|
|
47
|
+
showFieldIcons,
|
|
48
|
+
showJoinerToggle,
|
|
49
|
+
isComplexEnabled,
|
|
50
|
+
}: NestedGroupProps) {
|
|
51
|
+
const elementCount = (group.elements?.length ?? 0) + (group.groups?.length ?? 0)
|
|
52
|
+
const showJoinerSegment = showJoinerToggle && elementCount >= 2
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex items-stretch gap-2 px-1.5">
|
|
56
|
+
{/* Joiner gutter — same width as a FilterRow's gutter so siblings line
|
|
57
|
+
up vertically. `px-1.5` matches FilterRow's horizontal padding so
|
|
58
|
+
the gutter labels and the group-panel left edge align with sibling
|
|
59
|
+
rows. `pt-2` aligns the label with the group header text. */}
|
|
60
|
+
<span className="w-12 shrink-0 inline-flex justify-end pr-1 pt-2">
|
|
61
|
+
<span
|
|
62
|
+
className={cn(
|
|
63
|
+
'font-mono text-[10.5px] tracking-[0.08em]',
|
|
64
|
+
index === 0 ? 'text-ink-4' : 'text-accent-ink',
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{index === 0 ? 'WHERE' : joiner.toUpperCase()}
|
|
68
|
+
</span>
|
|
69
|
+
</span>
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex-1 min-w-0 rounded-sm border border-rule',
|
|
74
|
+
'bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{/* Group header — eyebrow + AND/OR + remove */}
|
|
78
|
+
<div className="flex items-center justify-between px-2.5 py-1.5 border-b border-rule">
|
|
79
|
+
<div className="flex items-center gap-2">
|
|
80
|
+
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
81
|
+
Group
|
|
82
|
+
</span>
|
|
83
|
+
{showJoinerSegment ? (
|
|
84
|
+
<SegmentedControl<LogicalOperator>
|
|
85
|
+
aria-label="Group joiner"
|
|
86
|
+
options={[
|
|
87
|
+
{ id: 'and', label: 'AND' },
|
|
88
|
+
{ id: 'or', label: 'OR' },
|
|
89
|
+
]}
|
|
90
|
+
value={group.logicalOperator}
|
|
91
|
+
onChange={(next) => onChange({ ...group, logicalOperator: next })}
|
|
92
|
+
/>
|
|
93
|
+
) : (
|
|
94
|
+
<span className="font-mono text-[10.5px] tracking-[0.08em] text-ink-3 uppercase">
|
|
95
|
+
{group.logicalOperator}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={onRemove}
|
|
102
|
+
aria-label="Remove group"
|
|
103
|
+
className={cn(
|
|
104
|
+
'size-6 inline-flex items-center justify-center rounded-sm',
|
|
105
|
+
'text-ink-4 hover:text-danger hover:bg-surface cursor-pointer outline-none',
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<svg
|
|
109
|
+
aria-hidden
|
|
110
|
+
viewBox="0 0 14 14"
|
|
111
|
+
className="size-3.5"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth="1.4"
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
>
|
|
118
|
+
<path d="M3 4h8M5 4V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1M4.5 4l.5 7a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1l.5-7" />
|
|
119
|
+
</svg>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="px-1.5 py-1.5">
|
|
124
|
+
<GroupBody
|
|
125
|
+
group={group}
|
|
126
|
+
fields={fields}
|
|
127
|
+
fieldsByPath={fieldsByPath}
|
|
128
|
+
onChange={onChange}
|
|
129
|
+
isRoot={false}
|
|
130
|
+
isComplexEnabled={isComplexEnabled}
|
|
131
|
+
showFieldIcons={showFieldIcons}
|
|
132
|
+
showJoinerToggle={showJoinerToggle}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn'
|
|
2
|
+
import type { FieldSize } from '../field/field'
|
|
3
|
+
import { Select, type SelectOption } from '../select/select'
|
|
4
|
+
import { operatorsForType } from './operators'
|
|
5
|
+
import type { ComparisonOperator, FilterFieldType } from './types'
|
|
6
|
+
|
|
7
|
+
/* =========================================================================
|
|
8
|
+
OperatorPicker — small dropdown of operators valid for the given field
|
|
9
|
+
type. Wraps lib `Select` and inherits its compact `sm` chrome so it lines
|
|
10
|
+
up byte-for-byte with the AttributePicker beside it.
|
|
11
|
+
========================================================================= */
|
|
12
|
+
|
|
13
|
+
export type OperatorPickerProps = {
|
|
14
|
+
fieldType: FilterFieldType
|
|
15
|
+
value: ComparisonOperator
|
|
16
|
+
onChange: (next: ComparisonOperator) => void
|
|
17
|
+
isDisabled?: boolean
|
|
18
|
+
/** Auto-focus the trigger on mount. Used by the chip mini-editor so focus
|
|
19
|
+
* lands on the operator (the field is already chosen, the operator is the
|
|
20
|
+
* natural next thing to tweak). */
|
|
21
|
+
autoFocus?: boolean
|
|
22
|
+
/** Field size. Defaults to `sm` to match the filter row. */
|
|
23
|
+
size?: FieldSize
|
|
24
|
+
/** Visual override for the trigger wrapper (sizing). */
|
|
25
|
+
className?: string
|
|
26
|
+
/** Per-field allowlist override (LUM-34). When provided, picks operators
|
|
27
|
+
* from this list instead of `operatorsForType(fieldType)`. */
|
|
28
|
+
operators?: ReadonlyArray<ComparisonOperator>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function OperatorPicker({
|
|
32
|
+
fieldType,
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
isDisabled,
|
|
36
|
+
autoFocus,
|
|
37
|
+
size = 'sm',
|
|
38
|
+
className,
|
|
39
|
+
operators,
|
|
40
|
+
}: OperatorPickerProps) {
|
|
41
|
+
const allForType = operatorsForType(fieldType)
|
|
42
|
+
const ops = operators ? allForType.filter((op) => operators.includes(op.id)) : allForType
|
|
43
|
+
const options: SelectOption<ComparisonOperator>[] = ops.map((op) => ({
|
|
44
|
+
id: op.id,
|
|
45
|
+
label: op.label,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Select<ComparisonOperator>
|
|
50
|
+
aria-label="Operator"
|
|
51
|
+
options={options}
|
|
52
|
+
selectedKey={value}
|
|
53
|
+
onSelectionChange={(next) => onChange(next as ComparisonOperator)}
|
|
54
|
+
size={size}
|
|
55
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
56
|
+
{...(autoFocus ? { autoFocus: true } : {})}
|
|
57
|
+
className={cn('w-full', className)}
|
|
58
|
+
// `min-w-0` lets the trigger shrink so its <SelectValue> can truncate.
|
|
59
|
+
triggerClassName="min-w-0"
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
|
|
4
|
+
|
|
5
|
+
describe('operatorsForType', () => {
|
|
6
|
+
it('includes eq/ne for every primitive type', () => {
|
|
7
|
+
for (const t of ['string', 'number', 'integer', 'datetime', 'enum', 'boolean'] as const) {
|
|
8
|
+
const ops = operatorsForType(t).map((o) => o.id)
|
|
9
|
+
expect(ops).toContain('eq')
|
|
10
|
+
expect(ops).toContain('ne')
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('only allows starts_with / ends_with on strings', () => {
|
|
15
|
+
for (const op of ['starts_with', 'ends_with'] as const) {
|
|
16
|
+
const types = OPERATORS.find((o) => o.id === op)?.types ?? []
|
|
17
|
+
expect(types).toEqual(['string'])
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('allows in / not_in on strings, numbers, and enums (not datetime/boolean/array)', () => {
|
|
22
|
+
for (const op of ['in', 'not_in'] as const) {
|
|
23
|
+
const types = new Set(OPERATORS.find((o) => o.id === op)?.types ?? [])
|
|
24
|
+
expect(types.has('string')).toBe(true)
|
|
25
|
+
expect(types.has('number')).toBe(true)
|
|
26
|
+
expect(types.has('integer')).toBe(true)
|
|
27
|
+
expect(types.has('enum')).toBe(true)
|
|
28
|
+
expect(types.has('datetime')).toBe(false)
|
|
29
|
+
expect(types.has('boolean')).toBe(false)
|
|
30
|
+
expect(types.has('array')).toBe(false)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('only allows gte/lte on datetime', () => {
|
|
35
|
+
for (const op of ['gte', 'lte'] as const) {
|
|
36
|
+
const types = OPERATORS.find((o) => o.id === op)?.types ?? []
|
|
37
|
+
expect(types).toEqual(['datetime'])
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('allows contains/empty/not_empty on arrays', () => {
|
|
42
|
+
const arrayOps = operatorsForType('array').map((o) => o.id)
|
|
43
|
+
expect(arrayOps).toContain('contains')
|
|
44
|
+
expect(arrayOps).toContain('empty')
|
|
45
|
+
expect(arrayOps).toContain('not_empty')
|
|
46
|
+
// No magnitude/text-shape operators on arrays.
|
|
47
|
+
expect(arrayOps).not.toContain('gt')
|
|
48
|
+
expect(arrayOps).not.toContain('starts_with')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('allows identity operators on user, not text/numeric shapes', () => {
|
|
52
|
+
const userOps = operatorsForType('user').map((o) => o.id)
|
|
53
|
+
expect(userOps).toEqual(
|
|
54
|
+
expect.arrayContaining(['eq', 'ne', 'in', 'not_in', 'empty', 'not_empty']),
|
|
55
|
+
)
|
|
56
|
+
expect(userOps).not.toContain('contains')
|
|
57
|
+
expect(userOps).not.toContain('starts_with')
|
|
58
|
+
expect(userOps).not.toContain('gt')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('overrides labels per field type where natural', () => {
|
|
62
|
+
const numberEq = operatorsForType('number').find((o) => o.id === 'eq')
|
|
63
|
+
expect(numberEq?.label).toBe('equals')
|
|
64
|
+
|
|
65
|
+
const datetimeGt = operatorsForType('datetime').find((o) => o.id === 'gt')
|
|
66
|
+
expect(datetimeGt?.label).toBe('is after')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('arityOf', () => {
|
|
71
|
+
it('returns 0 for empty/not_empty', () => {
|
|
72
|
+
expect(arityOf('empty')).toBe(0)
|
|
73
|
+
expect(arityOf('not_empty')).toBe(0)
|
|
74
|
+
})
|
|
75
|
+
it("returns 'n' for in/not_in", () => {
|
|
76
|
+
expect(arityOf('in')).toBe('n')
|
|
77
|
+
expect(arityOf('not_in')).toBe('n')
|
|
78
|
+
})
|
|
79
|
+
it('returns 1 for the rest', () => {
|
|
80
|
+
expect(arityOf('eq')).toBe(1)
|
|
81
|
+
expect(arityOf('contains')).toBe(1)
|
|
82
|
+
expect(arityOf('gt')).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('defaultValueFor', () => {
|
|
87
|
+
it('returns null for arity 0', () => {
|
|
88
|
+
expect(defaultValueFor('string', 0)).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
it("returns [] for arity 'n'", () => {
|
|
91
|
+
expect(defaultValueFor('enum', 'n')).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
it('returns true for boolean fields, empty string otherwise', () => {
|
|
94
|
+
expect(defaultValueFor('boolean', 1)).toBe(true)
|
|
95
|
+
expect(defaultValueFor('string', 1)).toBe('')
|
|
96
|
+
expect(defaultValueFor('number', 1)).toBe('')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('operatorById', () => {
|
|
101
|
+
it('finds known operators', () => {
|
|
102
|
+
expect(operatorById('eq')?.label).toBe('is')
|
|
103
|
+
expect(operatorById('contains')?.arity).toBe(1)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComparisonOperator,
|
|
3
|
+
FilterFieldType,
|
|
4
|
+
FilterValue,
|
|
5
|
+
OperatorArity,
|
|
6
|
+
OperatorDescriptor,
|
|
7
|
+
} from './types'
|
|
8
|
+
|
|
9
|
+
/* =========================================================================
|
|
10
|
+
Operator catalogue — single source of truth for which operators apply
|
|
11
|
+
to which field types and how many values each takes. Mirrors the
|
|
12
|
+
backend enum at `core-model/comparison-operator.go` and the design's
|
|
13
|
+
`OPERATORS` table.
|
|
14
|
+
========================================================================= */
|
|
15
|
+
|
|
16
|
+
export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
|
|
17
|
+
{
|
|
18
|
+
id: 'eq',
|
|
19
|
+
label: 'is',
|
|
20
|
+
arity: 1,
|
|
21
|
+
types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'ne',
|
|
25
|
+
label: 'is not',
|
|
26
|
+
arity: 1,
|
|
27
|
+
types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'in',
|
|
31
|
+
label: 'is any of',
|
|
32
|
+
arity: 'n',
|
|
33
|
+
types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'not_in',
|
|
37
|
+
label: 'is none of',
|
|
38
|
+
arity: 'n',
|
|
39
|
+
types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
|
|
40
|
+
},
|
|
41
|
+
{ id: 'contains', label: 'contains', arity: 1, types: ['string', 'array'] },
|
|
42
|
+
{ id: 'starts_with', label: 'starts with', arity: 1, types: ['string'] },
|
|
43
|
+
{ id: 'ends_with', label: 'ends with', arity: 1, types: ['string'] },
|
|
44
|
+
{ id: 'gt', label: 'is greater than', arity: 1, types: ['number', 'integer', 'currency', 'datetime'] },
|
|
45
|
+
{ id: 'gte', label: 'is on or after', arity: 1, types: ['datetime'] },
|
|
46
|
+
{ id: 'lt', label: 'is less than', arity: 1, types: ['number', 'integer', 'currency', 'datetime'] },
|
|
47
|
+
{ id: 'lte', label: 'is on or before', arity: 1, types: ['datetime'] },
|
|
48
|
+
{
|
|
49
|
+
id: 'empty',
|
|
50
|
+
label: 'is empty',
|
|
51
|
+
arity: 0,
|
|
52
|
+
types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'not_empty',
|
|
56
|
+
label: 'is not empty',
|
|
57
|
+
arity: 0,
|
|
58
|
+
types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const OP_LABEL_OVERRIDES: Partial<
|
|
63
|
+
Record<FilterFieldType, Partial<Record<ComparisonOperator, string>>>
|
|
64
|
+
> = {
|
|
65
|
+
number: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
|
|
66
|
+
integer: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
|
|
67
|
+
currency: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
|
|
68
|
+
datetime: { eq: 'is on', ne: 'is not on', gt: 'is after', lt: 'is before' },
|
|
69
|
+
array: { contains: 'contains', empty: 'is empty', not_empty: 'is not empty' },
|
|
70
|
+
boolean: { eq: 'is', ne: 'is not' },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function operatorsForType(type: FilterFieldType): ReadonlyArray<OperatorDescriptor> {
|
|
74
|
+
return OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
|
|
75
|
+
const override = OP_LABEL_OVERRIDES[type]?.[op.id]
|
|
76
|
+
return override ? { ...op, label: override } : op
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function operatorById(id: ComparisonOperator): OperatorDescriptor | undefined {
|
|
81
|
+
return OPERATORS.find((o) => o.id === id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function arityOf(operator: ComparisonOperator): OperatorArity {
|
|
85
|
+
return operatorById(operator)?.arity ?? 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* =========================================================================
|
|
89
|
+
Default value for a fresh row, given the field type and the chosen
|
|
90
|
+
operator's arity. Keeps the value shape consistent with `FilterValue`.
|
|
91
|
+
========================================================================= */
|
|
92
|
+
export function defaultValueFor(type: FilterFieldType, arity: OperatorArity): FilterValue {
|
|
93
|
+
if (arity === 0) return null
|
|
94
|
+
if (arity === 'n') return []
|
|
95
|
+
if (type === 'boolean') return true
|
|
96
|
+
return ''
|
|
97
|
+
}
|