@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,351 @@
|
|
|
1
|
+
import { type CalendarDate, parseDate } from '@internationalized/date'
|
|
2
|
+
import { createContext, type ReactNode, useContext } from 'react'
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
import { Badge } from '../badge/badge'
|
|
5
|
+
import {
|
|
6
|
+
BadgeMultiSelect,
|
|
7
|
+
type BadgeMultiSelectOption,
|
|
8
|
+
} from '../badge-multi-select/badge-multi-select'
|
|
9
|
+
import { DatePicker } from '../date-picker/date-picker'
|
|
10
|
+
import { Input } from '../input/input'
|
|
11
|
+
import { SegmentedControl } from '../segmented-control/segmented-control'
|
|
12
|
+
import { Select, type SelectOption } from '../select/select'
|
|
13
|
+
import { TagInput } from '../tag-input/tag-input'
|
|
14
|
+
import { arityOf } from './operators'
|
|
15
|
+
import type { ComparisonOperator, FilterField, FilterValue } from './types'
|
|
16
|
+
|
|
17
|
+
/* =========================================================================
|
|
18
|
+
FilterValueInput — type-aware value editor for a single filter element.
|
|
19
|
+
Routes to the right primitive based on `(field.type, operator.arity)`:
|
|
20
|
+
|
|
21
|
+
arity 0 → null (no input — for empty / not_empty)
|
|
22
|
+
arity 'n' (in / not_in)
|
|
23
|
+
enum → BadgeMultiSelect
|
|
24
|
+
other → TagInput (free-form list)
|
|
25
|
+
arity 1
|
|
26
|
+
enum → Select with Badge in renderItem
|
|
27
|
+
boolean → SegmentedControl (true / false)
|
|
28
|
+
datetime → DatePicker
|
|
29
|
+
other → Input (text or number)
|
|
30
|
+
========================================================================= */
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render-prop signature for overriding the default value editor. Called
|
|
34
|
+
* with everything `FilterValueInput` knows about the leaf. Return a
|
|
35
|
+
* `ReactNode` to take over rendering, or `undefined` to fall through to
|
|
36
|
+
* the built-in branches (the common pattern: override one specific
|
|
37
|
+
* `field.type`, return `undefined` for the rest).
|
|
38
|
+
*
|
|
39
|
+
* Used to plug in product-specific editors the lib can't know about —
|
|
40
|
+
* e.g. a related-record picker that hits the SDK for typeahead.
|
|
41
|
+
*/
|
|
42
|
+
export type FilterValueInputRenderer = (props: {
|
|
43
|
+
field: FilterField
|
|
44
|
+
operator: ComparisonOperator
|
|
45
|
+
value: FilterValue
|
|
46
|
+
onChange: (next: FilterValue) => void
|
|
47
|
+
autoFocus?: boolean
|
|
48
|
+
isDisabled?: boolean
|
|
49
|
+
}) => ReactNode | undefined
|
|
50
|
+
|
|
51
|
+
/* =========================================================================
|
|
52
|
+
Context — the renderer is injected via React context rather than a
|
|
53
|
+
prop drilled through five levels of intermediates. Top-level entry
|
|
54
|
+
points (RecordFilterEditor, RecordFilterButton, RecordFilterChips,
|
|
55
|
+
RecordFilterBuilderPanel) wrap their tree in `RenderValueInputProvider`;
|
|
56
|
+
`FilterValueInput` reads from `useRenderValueInput()`. Adding future
|
|
57
|
+
slots (operator picker, attribute picker) follows the same shape
|
|
58
|
+
without growing the public prop surface of internal components.
|
|
59
|
+
========================================================================= */
|
|
60
|
+
|
|
61
|
+
const RenderValueInputContext = createContext<FilterValueInputRenderer | undefined>(undefined)
|
|
62
|
+
|
|
63
|
+
export function RenderValueInputProvider({
|
|
64
|
+
renderer,
|
|
65
|
+
children,
|
|
66
|
+
}: {
|
|
67
|
+
renderer: FilterValueInputRenderer | undefined
|
|
68
|
+
children: ReactNode
|
|
69
|
+
}) {
|
|
70
|
+
// Skip wrapping when there's no renderer so intermediate components
|
|
71
|
+
// (e.g. RecordFilterEditor when invoked inside RecordFilterBuilderPanel)
|
|
72
|
+
// don't shadow the outer provider's value with `undefined`. The closest
|
|
73
|
+
// provider wins in React's context model, so an undefined-wrapping
|
|
74
|
+
// intermediate would silently break the chain from outer entry points.
|
|
75
|
+
if (renderer === undefined) return <>{children}</>
|
|
76
|
+
return (
|
|
77
|
+
<RenderValueInputContext.Provider value={renderer}>{children}</RenderValueInputContext.Provider>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useRenderValueInput(): FilterValueInputRenderer | undefined {
|
|
82
|
+
return useContext(RenderValueInputContext)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type FilterValueInputProps = {
|
|
86
|
+
field: FilterField
|
|
87
|
+
operator: ComparisonOperator
|
|
88
|
+
value: FilterValue
|
|
89
|
+
onChange: (next: FilterValue) => void
|
|
90
|
+
isDisabled?: boolean
|
|
91
|
+
/** When true, focus the routed leaf on mount. The wrapping `<div>` is
|
|
92
|
+
* keyed on `${field.path}-${operator}`, so an operator change remounts
|
|
93
|
+
* the leaf and re-fires its `autoFocus` reliably across all six leaf
|
|
94
|
+
* branches. Committed values come from props so they survive remount;
|
|
95
|
+
* mid-typed drafts (TagInput buffer, DatePicker partial segment,
|
|
96
|
+
* Combobox search) are reset — acceptable since the user took the
|
|
97
|
+
* deliberate operator-change action. */
|
|
98
|
+
autoFocus?: boolean
|
|
99
|
+
className?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function FilterValueInput({
|
|
103
|
+
field,
|
|
104
|
+
operator,
|
|
105
|
+
value,
|
|
106
|
+
onChange,
|
|
107
|
+
isDisabled,
|
|
108
|
+
autoFocus = false,
|
|
109
|
+
className,
|
|
110
|
+
}: FilterValueInputProps) {
|
|
111
|
+
const renderValueInput = useRenderValueInput()
|
|
112
|
+
const arity = arityOf(operator)
|
|
113
|
+
if (arity === 0) return null
|
|
114
|
+
|
|
115
|
+
// Context-provided override (e.g. relation picker). Called first so
|
|
116
|
+
// it can claim any (field.type, operator) combination; returning
|
|
117
|
+
// `undefined` falls through to the built-in branches below.
|
|
118
|
+
if (renderValueInput) {
|
|
119
|
+
const custom = renderValueInput({
|
|
120
|
+
field,
|
|
121
|
+
operator,
|
|
122
|
+
value,
|
|
123
|
+
onChange,
|
|
124
|
+
autoFocus,
|
|
125
|
+
...(isDisabled !== undefined ? { isDisabled } : {}),
|
|
126
|
+
})
|
|
127
|
+
if (custom !== undefined) {
|
|
128
|
+
return <div key={`${field.path}-${operator}`}>{custom}</div>
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const leaf = (() => {
|
|
133
|
+
/* ---------- multi-value (in / not_in) ---------- */
|
|
134
|
+
if (arity === 'n') {
|
|
135
|
+
if (field.type === 'enum') {
|
|
136
|
+
return (
|
|
137
|
+
<EnumMultiValue
|
|
138
|
+
field={field}
|
|
139
|
+
value={asStringArray(value)}
|
|
140
|
+
onChange={(next) => onChange(next)}
|
|
141
|
+
autoFocus={autoFocus}
|
|
142
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
143
|
+
{...(className !== undefined ? { className } : {})}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
<TagInput
|
|
149
|
+
size="sm"
|
|
150
|
+
value={asStringArray(value)}
|
|
151
|
+
onChange={(next) => onChange(next)}
|
|
152
|
+
inputType={
|
|
153
|
+
field.type === 'number' || field.type === 'integer' || field.type === 'currency'
|
|
154
|
+
? 'number'
|
|
155
|
+
: 'text'
|
|
156
|
+
}
|
|
157
|
+
placeholder="Type, Enter to add"
|
|
158
|
+
autoFocus={autoFocus}
|
|
159
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
160
|
+
{...(className !== undefined ? { className } : {})}
|
|
161
|
+
/>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ---------- single value ---------- */
|
|
166
|
+
if (field.type === 'enum') {
|
|
167
|
+
return (
|
|
168
|
+
<EnumSingleValue
|
|
169
|
+
field={field}
|
|
170
|
+
value={asString(value)}
|
|
171
|
+
onChange={(next) => onChange(next)}
|
|
172
|
+
autoFocus={autoFocus}
|
|
173
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
174
|
+
{...(className !== undefined ? { className } : {})}
|
|
175
|
+
/>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (field.type === 'boolean') {
|
|
180
|
+
return (
|
|
181
|
+
<SegmentedControl<'true' | 'false'>
|
|
182
|
+
size="md"
|
|
183
|
+
aria-label="Boolean value"
|
|
184
|
+
options={[
|
|
185
|
+
{ id: 'true', label: 'true' },
|
|
186
|
+
{ id: 'false', label: 'false' },
|
|
187
|
+
]}
|
|
188
|
+
value={asBoolean(value) ? 'true' : 'false'}
|
|
189
|
+
onChange={(v) => onChange(v === 'true')}
|
|
190
|
+
autoFocus={autoFocus}
|
|
191
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
192
|
+
{...(className !== undefined ? { className } : {})}
|
|
193
|
+
/>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (field.type === 'datetime') {
|
|
198
|
+
const stringValue = asString(value)
|
|
199
|
+
let parsed: CalendarDate | null = null
|
|
200
|
+
try {
|
|
201
|
+
parsed = stringValue ? parseDate(stringValue) : null
|
|
202
|
+
} catch {
|
|
203
|
+
parsed = null
|
|
204
|
+
}
|
|
205
|
+
return (
|
|
206
|
+
<DatePicker
|
|
207
|
+
size="sm"
|
|
208
|
+
aria-label={field.label}
|
|
209
|
+
value={parsed}
|
|
210
|
+
onChange={(next) => onChange(next ? next.toString() : '')}
|
|
211
|
+
autoFocus={autoFocus}
|
|
212
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
213
|
+
className={cn('w-full', className)}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// string / number / integer
|
|
219
|
+
return (
|
|
220
|
+
<Input
|
|
221
|
+
size="sm"
|
|
222
|
+
aria-label={field.label}
|
|
223
|
+
value={asString(value)}
|
|
224
|
+
onChange={(next) => onChange(next)}
|
|
225
|
+
placeholder={placeholderFor(field, operator)}
|
|
226
|
+
inputProps={{
|
|
227
|
+
type:
|
|
228
|
+
field.type === 'number' || field.type === 'integer' || field.type === 'currency'
|
|
229
|
+
? 'number'
|
|
230
|
+
: (field.inputType ?? 'text'),
|
|
231
|
+
...(autoFocus ? { autoFocus: true } : {}),
|
|
232
|
+
}}
|
|
233
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
234
|
+
className={cn('w-full', className)}
|
|
235
|
+
/>
|
|
236
|
+
)
|
|
237
|
+
})()
|
|
238
|
+
|
|
239
|
+
// Key forces a fresh mount on every operator change so the leaf's
|
|
240
|
+
// `autoFocus` re-fires reliably even when the leaf component type is
|
|
241
|
+
// unchanged (e.g. `eq` → `not_eq`, both string-arity-1 → both `Input`).
|
|
242
|
+
return <div key={`${field.path}-${operator}`}>{leaf}</div>
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ----- enum single via lib `Select` with Badge in renderItem ----------- */
|
|
246
|
+
function EnumSingleValue({
|
|
247
|
+
field,
|
|
248
|
+
value,
|
|
249
|
+
onChange,
|
|
250
|
+
isDisabled,
|
|
251
|
+
autoFocus,
|
|
252
|
+
className,
|
|
253
|
+
}: {
|
|
254
|
+
field: FilterField
|
|
255
|
+
value: string
|
|
256
|
+
onChange: (next: string) => void
|
|
257
|
+
isDisabled?: boolean
|
|
258
|
+
autoFocus?: boolean
|
|
259
|
+
className?: string
|
|
260
|
+
}) {
|
|
261
|
+
const options: SelectOption<string>[] = (field.options ?? []).map((o) => ({
|
|
262
|
+
id: o.value,
|
|
263
|
+
label: o.label,
|
|
264
|
+
}))
|
|
265
|
+
return (
|
|
266
|
+
<Select<string>
|
|
267
|
+
size="sm"
|
|
268
|
+
aria-label={field.label}
|
|
269
|
+
options={options}
|
|
270
|
+
selectedKey={value === '' ? null : value}
|
|
271
|
+
onSelectionChange={(next) => onChange(next == null ? '' : String(next))}
|
|
272
|
+
renderItem={(o) => {
|
|
273
|
+
const variant = field.options?.find((x) => x.value === o.id)?.variant
|
|
274
|
+
return <Badge variant={variant}>{o.label}</Badge>
|
|
275
|
+
}}
|
|
276
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
277
|
+
{...(autoFocus ? { autoFocus: true } : {})}
|
|
278
|
+
className={cn('w-full', className)}
|
|
279
|
+
/>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ----- enum multi via lib `BadgeMultiSelect` --------------------------- */
|
|
284
|
+
function EnumMultiValue({
|
|
285
|
+
field,
|
|
286
|
+
value,
|
|
287
|
+
onChange,
|
|
288
|
+
isDisabled,
|
|
289
|
+
autoFocus,
|
|
290
|
+
className,
|
|
291
|
+
}: {
|
|
292
|
+
field: FilterField
|
|
293
|
+
value: string[]
|
|
294
|
+
onChange: (next: string[]) => void
|
|
295
|
+
isDisabled?: boolean
|
|
296
|
+
autoFocus?: boolean
|
|
297
|
+
className?: string
|
|
298
|
+
}) {
|
|
299
|
+
const options: BadgeMultiSelectOption<string>[] = (field.options ?? []).map((o) => ({
|
|
300
|
+
id: o.value,
|
|
301
|
+
label: o.label,
|
|
302
|
+
...(o.variant ? { variant: o.variant } : {}),
|
|
303
|
+
}))
|
|
304
|
+
return (
|
|
305
|
+
<BadgeMultiSelect<string>
|
|
306
|
+
size="sm"
|
|
307
|
+
options={options}
|
|
308
|
+
selectedKeys={value}
|
|
309
|
+
onSelectionChange={(keys) => onChange(Array.from(keys))}
|
|
310
|
+
placeholder="Select values…"
|
|
311
|
+
{...(isDisabled !== undefined ? { isDisabled } : {})}
|
|
312
|
+
{...(autoFocus ? { autoFocus: true } : {})}
|
|
313
|
+
className={cn('w-full', className)}
|
|
314
|
+
/>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ----- value coercers --------------------------------------------------- */
|
|
319
|
+
|
|
320
|
+
function asString(v: FilterValue): string {
|
|
321
|
+
if (v == null) return ''
|
|
322
|
+
if (typeof v === 'string') return v
|
|
323
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
324
|
+
if (Array.isArray(v)) return v.join(',')
|
|
325
|
+
return ''
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function asStringArray(v: FilterValue): string[] {
|
|
329
|
+
if (Array.isArray(v)) return v
|
|
330
|
+
if (typeof v === 'string' && v.length > 0)
|
|
331
|
+
return v
|
|
332
|
+
.split(',')
|
|
333
|
+
.map((s) => s.trim())
|
|
334
|
+
.filter(Boolean)
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function asBoolean(v: FilterValue): boolean {
|
|
339
|
+
if (typeof v === 'boolean') return v
|
|
340
|
+
if (typeof v === 'string') return v === 'true'
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function placeholderFor(field: FilterField, operator: ComparisonOperator): string {
|
|
345
|
+
if (field.type === 'datetime') return 'yyyy-mm-dd'
|
|
346
|
+
if (field.type === 'number' || field.type === 'integer' || field.type === 'currency') return '0'
|
|
347
|
+
if (operator === 'starts_with') return 'starts with…'
|
|
348
|
+
if (operator === 'ends_with') return 'ends with…'
|
|
349
|
+
if (operator === 'contains') return 'contains…'
|
|
350
|
+
return 'value'
|
|
351
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
GroupAddControls — "+ Add filter" / "+ Add group" footer rendered at the
|
|
5
|
+
bottom of any group body. The "+ Add group" affordance is gated behind
|
|
6
|
+
`isComplexEnabled`; in flat mode (the back-compat default) only "+ Add
|
|
7
|
+
filter" appears, exactly matching the pre-nesting builder panel.
|
|
8
|
+
|
|
9
|
+
"+ Add filter" follows the existing pattern: it adds a *pending* row
|
|
10
|
+
(field === '') whose AttributePicker auto-focuses on mount, rather than
|
|
11
|
+
opening a separate popover. Keeps consistency with `RecordFilterChips`
|
|
12
|
+
and avoids a second popover layer inside the builder.
|
|
13
|
+
========================================================================= */
|
|
14
|
+
|
|
15
|
+
export type GroupAddControlsProps = {
|
|
16
|
+
onAddFilter: () => void
|
|
17
|
+
onAddGroup?: () => void
|
|
18
|
+
isComplexEnabled: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function GroupAddControls({
|
|
22
|
+
onAddFilter,
|
|
23
|
+
onAddGroup,
|
|
24
|
+
isComplexEnabled,
|
|
25
|
+
}: GroupAddControlsProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-1 pl-12 pt-1">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onAddFilter}
|
|
31
|
+
className={cn(
|
|
32
|
+
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm',
|
|
33
|
+
'text-ink-2 hover:bg-surface-2 hover:text-ink cursor-pointer outline-none',
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<svg
|
|
37
|
+
aria-hidden
|
|
38
|
+
viewBox="0 0 12 12"
|
|
39
|
+
className="size-3"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="1.5"
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
>
|
|
45
|
+
<path d="M6 2v8M2 6h8" />
|
|
46
|
+
</svg>
|
|
47
|
+
<span className="font-sans text-[12.5px]">Add filter</span>
|
|
48
|
+
</button>
|
|
49
|
+
{isComplexEnabled && onAddGroup ? (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={onAddGroup}
|
|
53
|
+
title="Add a nested group with its own logical operator"
|
|
54
|
+
className={cn(
|
|
55
|
+
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm',
|
|
56
|
+
'text-ink-2 hover:bg-surface-2 hover:text-ink cursor-pointer outline-none',
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<svg
|
|
60
|
+
aria-hidden
|
|
61
|
+
viewBox="0 0 14 14"
|
|
62
|
+
className="size-3"
|
|
63
|
+
fill="none"
|
|
64
|
+
stroke="currentColor"
|
|
65
|
+
strokeWidth="1.5"
|
|
66
|
+
strokeLinecap="round"
|
|
67
|
+
strokeLinejoin="round"
|
|
68
|
+
>
|
|
69
|
+
<path d="M5 3a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3M9 3a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3" />
|
|
70
|
+
<path d="M7 5v4M5 7h4" />
|
|
71
|
+
</svg>
|
|
72
|
+
<span className="font-sans text-[12.5px]">Add group</span>
|
|
73
|
+
</button>
|
|
74
|
+
) : null}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { FilterRow, rebaseRowOnFieldChange, rebaseRowOnOperatorChange } from './filter-row'
|
|
2
|
+
import { GroupAddControls } from './group-add-controls'
|
|
3
|
+
import {
|
|
4
|
+
addPendingLeaf,
|
|
5
|
+
addSubgroup,
|
|
6
|
+
removeLeaf,
|
|
7
|
+
removeSubgroup,
|
|
8
|
+
replaceLeaf,
|
|
9
|
+
updateLeaf,
|
|
10
|
+
updateSubgroup,
|
|
11
|
+
wrapLeafInGroup,
|
|
12
|
+
} from './group-mutations'
|
|
13
|
+
import { NestedGroup } from './nested-group'
|
|
14
|
+
import { arityOf } from './operators'
|
|
15
|
+
import type { FilterField, FilterGroup } from './types'
|
|
16
|
+
|
|
17
|
+
/* =========================================================================
|
|
18
|
+
GroupBody — body of a single FilterGroup, used at the root of the popover
|
|
19
|
+
panel and recursively inside `NestedGroup`. Renders:
|
|
20
|
+
|
|
21
|
+
1. one `FilterRow` per leaf (`group.elements`)
|
|
22
|
+
2. one `NestedGroup` per sub-group (`group.groups`) — only when
|
|
23
|
+
`isComplexEnabled`; otherwise sub-groups are silently skipped (the
|
|
24
|
+
chip row still surfaces them as a read-only pill)
|
|
25
|
+
3. a `GroupAddControls` footer with "+ Add filter" (always) and
|
|
26
|
+
"+ Add group" (only when `isComplexEnabled`)
|
|
27
|
+
|
|
28
|
+
Auto-focus signals are only wired at the root — nested groups don't get
|
|
29
|
+
the field→operator→value focus chain. This is a deliberate scope cut:
|
|
30
|
+
adding to a nested group is a click action, and tracking root-vs-nested
|
|
31
|
+
focus paths through arbitrary depth wasn't worth the v1 complexity.
|
|
32
|
+
========================================================================= */
|
|
33
|
+
|
|
34
|
+
export type GroupBodyProps = {
|
|
35
|
+
group: FilterGroup
|
|
36
|
+
fields: ReadonlyArray<FilterField>
|
|
37
|
+
fieldsByPath: Record<string, FilterField>
|
|
38
|
+
onChange: (next: FilterGroup) => void
|
|
39
|
+
isRoot: boolean
|
|
40
|
+
isComplexEnabled: boolean
|
|
41
|
+
showFieldIcons: boolean
|
|
42
|
+
showJoinerToggle: boolean
|
|
43
|
+
/** Root-only auto-focus index for the AttributePicker. */
|
|
44
|
+
autoFocusIndex?: number | null
|
|
45
|
+
setAutoFocusIndex?: (next: number | null) => void
|
|
46
|
+
/** Root-only auto-focus index for the OperatorPicker. */
|
|
47
|
+
autoFocusOperatorIndex?: number | null
|
|
48
|
+
setAutoFocusOperatorIndex?: (next: number | null) => void
|
|
49
|
+
/** Root-only auto-focus index for the value cell. */
|
|
50
|
+
autoFocusValueIndex?: number | null
|
|
51
|
+
setAutoFocusValueIndex?: (next: number | null) => void
|
|
52
|
+
/** Optional submit chain — Enter on a text/number value adds a new pending
|
|
53
|
+
* row at the end of *this* group. Forwarded by the panel for the root. */
|
|
54
|
+
onSubmitLeaf?: () => void
|
|
55
|
+
/** Empty-state copy override. Defaults vary per `isRoot`. */
|
|
56
|
+
emptyText?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function GroupBody({
|
|
60
|
+
group,
|
|
61
|
+
fields,
|
|
62
|
+
fieldsByPath,
|
|
63
|
+
onChange,
|
|
64
|
+
isRoot,
|
|
65
|
+
isComplexEnabled,
|
|
66
|
+
showFieldIcons,
|
|
67
|
+
showJoinerToggle,
|
|
68
|
+
autoFocusIndex = null,
|
|
69
|
+
setAutoFocusIndex,
|
|
70
|
+
autoFocusOperatorIndex = null,
|
|
71
|
+
setAutoFocusOperatorIndex,
|
|
72
|
+
autoFocusValueIndex = null,
|
|
73
|
+
setAutoFocusValueIndex,
|
|
74
|
+
onSubmitLeaf,
|
|
75
|
+
emptyText,
|
|
76
|
+
}: GroupBodyProps) {
|
|
77
|
+
const elements = group.elements ?? []
|
|
78
|
+
const subgroups = group.groups ?? []
|
|
79
|
+
|
|
80
|
+
const handleAddFilter = () => {
|
|
81
|
+
const nextIndex = elements.length
|
|
82
|
+
onChange(addPendingLeaf(group))
|
|
83
|
+
setAutoFocusIndex?.(nextIndex)
|
|
84
|
+
}
|
|
85
|
+
const handleAddGroup = () => onChange(addSubgroup(group))
|
|
86
|
+
|
|
87
|
+
const handleRemoveLeaf = (i: number) => {
|
|
88
|
+
if (autoFocusIndex === i) setAutoFocusIndex?.(null)
|
|
89
|
+
if (autoFocusOperatorIndex === i) setAutoFocusOperatorIndex?.(null)
|
|
90
|
+
if (autoFocusValueIndex === i) setAutoFocusValueIndex?.(null)
|
|
91
|
+
onChange(removeLeaf(group, i))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isEmpty = elements.length === 0 && (!isComplexEnabled || subgroups.length === 0)
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex flex-col gap-1">
|
|
98
|
+
{isEmpty ? (
|
|
99
|
+
<div
|
|
100
|
+
className={
|
|
101
|
+
(isRoot ? 'px-3 py-6' : 'px-2 py-3') +
|
|
102
|
+
' text-center font-mono text-[11.5px] text-ink-4'
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
{emptyText ?? (isRoot
|
|
106
|
+
? 'No filters. Add one to narrow the list.'
|
|
107
|
+
: 'Empty group. Add a filter or another group.')}
|
|
108
|
+
</div>
|
|
109
|
+
) : null}
|
|
110
|
+
|
|
111
|
+
{elements.map((el, i) => {
|
|
112
|
+
const field = fieldsByPath[el.field]
|
|
113
|
+
return (
|
|
114
|
+
<FilterRow
|
|
115
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: filter elements have no stable id; index disambiguates duplicate-field rows
|
|
116
|
+
key={`${el.field}-${i}`}
|
|
117
|
+
index={i}
|
|
118
|
+
joiner={group.logicalOperator}
|
|
119
|
+
{...(field ? { field } : {})}
|
|
120
|
+
element={el}
|
|
121
|
+
fields={fields}
|
|
122
|
+
autoFocus={autoFocusIndex === i}
|
|
123
|
+
autoFocusOperator={autoFocusOperatorIndex === i}
|
|
124
|
+
onAutoFocusOperatorConsumed={() => setAutoFocusOperatorIndex?.(null)}
|
|
125
|
+
autoFocusValue={autoFocusValueIndex === i}
|
|
126
|
+
onAutoFocusValueConsumed={() => setAutoFocusValueIndex?.(null)}
|
|
127
|
+
{...(onSubmitLeaf ? { onSubmit: onSubmitLeaf } : {})}
|
|
128
|
+
onChangeField={(picked) => {
|
|
129
|
+
if (autoFocusIndex === i) setAutoFocusIndex?.(null)
|
|
130
|
+
setAutoFocusOperatorIndex?.(i)
|
|
131
|
+
onChange(replaceLeaf(group, i, rebaseRowOnFieldChange(el, picked)))
|
|
132
|
+
}}
|
|
133
|
+
onChangeOperator={(opId) => {
|
|
134
|
+
if (!field) return
|
|
135
|
+
if (arityOf(opId) !== 0) setAutoFocusValueIndex?.(i)
|
|
136
|
+
onChange(replaceLeaf(group, i, rebaseRowOnOperatorChange(el, field, opId)))
|
|
137
|
+
}}
|
|
138
|
+
onChangeValue={(v) => onChange(updateLeaf(group, i, { value: v }))}
|
|
139
|
+
onRemove={() => handleRemoveLeaf(i)}
|
|
140
|
+
{...(isComplexEnabled && field
|
|
141
|
+
? { onWrap: () => onChange(wrapLeafInGroup(group, i)) }
|
|
142
|
+
: {})}
|
|
143
|
+
/>
|
|
144
|
+
)
|
|
145
|
+
})}
|
|
146
|
+
|
|
147
|
+
{isComplexEnabled
|
|
148
|
+
? subgroups.map((sub, i) => (
|
|
149
|
+
<NestedGroup
|
|
150
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: sub-groups have no stable id
|
|
151
|
+
key={`group-${i}`}
|
|
152
|
+
index={elements.length + i}
|
|
153
|
+
joiner={group.logicalOperator}
|
|
154
|
+
group={sub}
|
|
155
|
+
fields={fields}
|
|
156
|
+
fieldsByPath={fieldsByPath}
|
|
157
|
+
onChange={(next) => onChange(updateSubgroup(group, i, next))}
|
|
158
|
+
onRemove={() => onChange(removeSubgroup(group, i))}
|
|
159
|
+
showFieldIcons={showFieldIcons}
|
|
160
|
+
showJoinerToggle={showJoinerToggle}
|
|
161
|
+
isComplexEnabled={isComplexEnabled}
|
|
162
|
+
/>
|
|
163
|
+
))
|
|
164
|
+
: null}
|
|
165
|
+
|
|
166
|
+
<GroupAddControls
|
|
167
|
+
onAddFilter={handleAddFilter}
|
|
168
|
+
onAddGroup={handleAddGroup}
|
|
169
|
+
isComplexEnabled={isComplexEnabled}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
addLeaf,
|
|
4
|
+
addSubgroup,
|
|
5
|
+
countLeaves,
|
|
6
|
+
removeLeaf,
|
|
7
|
+
removeSubgroup,
|
|
8
|
+
wrapLeafInGroup,
|
|
9
|
+
} from './group-mutations'
|
|
10
|
+
import type { FilterField, FilterGroup } from './types'
|
|
11
|
+
|
|
12
|
+
const stringField: FilterField = {
|
|
13
|
+
path: 'name',
|
|
14
|
+
label: 'Name',
|
|
15
|
+
shortLabel: 'Name',
|
|
16
|
+
type: 'string',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = (): FilterGroup => ({
|
|
20
|
+
logicalOperator: 'and',
|
|
21
|
+
elements: [
|
|
22
|
+
{ field: 'name', operator: 'contains', value: 'a' },
|
|
23
|
+
{ field: 'mrr', operator: 'gt', value: '1000' },
|
|
24
|
+
],
|
|
25
|
+
groups: [],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('group-mutations', () => {
|
|
29
|
+
it('addLeaf appends a new element with the first valid operator and default value', () => {
|
|
30
|
+
const before: FilterGroup = { logicalOperator: 'and', elements: [], groups: [] }
|
|
31
|
+
const after = addLeaf(before, stringField)
|
|
32
|
+
expect(after.elements).toHaveLength(1)
|
|
33
|
+
expect(after.elements?.[0]?.field).toBe('name')
|
|
34
|
+
expect(after.elements?.[0]?.operator).toBe('eq')
|
|
35
|
+
// Inputs are not mutated.
|
|
36
|
+
expect(before.elements).toHaveLength(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('addSubgroup uses the opposite joiner of its parent by default', () => {
|
|
40
|
+
const before: FilterGroup = { logicalOperator: 'and', elements: [], groups: [] }
|
|
41
|
+
const after = addSubgroup(before)
|
|
42
|
+
expect(after.groups).toHaveLength(1)
|
|
43
|
+
expect(after.groups?.[0]?.logicalOperator).toBe('or')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('removeLeaf deletes by index and is idempotent on an out-of-range index', () => {
|
|
47
|
+
const before = root()
|
|
48
|
+
const after = removeLeaf(before, 0)
|
|
49
|
+
expect(after.elements).toHaveLength(1)
|
|
50
|
+
expect(after.elements?.[0]?.field).toBe('mrr')
|
|
51
|
+
|
|
52
|
+
const noop = removeLeaf(after, 99)
|
|
53
|
+
expect(noop.elements).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('removeSubgroup deletes by index without touching elements', () => {
|
|
57
|
+
const before: FilterGroup = {
|
|
58
|
+
logicalOperator: 'and',
|
|
59
|
+
elements: [{ field: 'name', operator: 'eq', value: 'a' }],
|
|
60
|
+
groups: [
|
|
61
|
+
{ logicalOperator: 'or', elements: [], groups: [] },
|
|
62
|
+
{ logicalOperator: 'or', elements: [], groups: [] },
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
const after = removeSubgroup(before, 0)
|
|
66
|
+
expect(after.groups).toHaveLength(1)
|
|
67
|
+
expect(after.elements).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('wrapLeafInGroup moves the leaf into a new sub-group with the opposite joiner', () => {
|
|
71
|
+
const before = root()
|
|
72
|
+
const after = wrapLeafInGroup(before, 0)
|
|
73
|
+
expect(after.elements).toHaveLength(1)
|
|
74
|
+
expect(after.elements?.[0]?.field).toBe('mrr')
|
|
75
|
+
expect(after.groups).toHaveLength(1)
|
|
76
|
+
expect(after.groups?.[0]?.logicalOperator).toBe('or')
|
|
77
|
+
expect(after.groups?.[0]?.elements).toHaveLength(1)
|
|
78
|
+
expect(after.groups?.[0]?.elements?.[0]?.field).toBe('name')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('countLeaves recurses through nested groups', () => {
|
|
82
|
+
const tree: FilterGroup = {
|
|
83
|
+
logicalOperator: 'and',
|
|
84
|
+
elements: [{ field: 'a', operator: 'eq', value: '' }],
|
|
85
|
+
groups: [
|
|
86
|
+
{
|
|
87
|
+
logicalOperator: 'or',
|
|
88
|
+
elements: [
|
|
89
|
+
{ field: 'b', operator: 'eq', value: '' },
|
|
90
|
+
{ field: 'c', operator: 'eq', value: '' },
|
|
91
|
+
],
|
|
92
|
+
groups: [
|
|
93
|
+
{
|
|
94
|
+
logicalOperator: 'and',
|
|
95
|
+
elements: [{ field: 'd', operator: 'eq', value: '' }],
|
|
96
|
+
groups: [],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
expect(countLeaves(tree)).toBe(4)
|
|
103
|
+
})
|
|
104
|
+
})
|