@proteos/ui 0.54.1 → 0.56.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/dist/index.cjs +679 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -9
- package/dist/index.d.ts +84 -9
- package/dist/index.js +677 -324
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge/badge.stories.tsx +37 -1
- package/src/components/badge/badge.test.tsx +39 -2
- package/src/components/badge/badge.tsx +65 -1
- package/src/components/badge/index.ts +2 -2
- package/src/components/badge-group/badge-group.stories.tsx +75 -0
- package/src/components/badge-group/badge-group.test.tsx +121 -0
- package/src/components/badge-group/badge-group.tsx +208 -0
- package/src/components/badge-group/index.ts +7 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
- package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
- package/src/components/data-table/data-table.stories.tsx +53 -2
- package/src/components/data-table/data-table.tsx +45 -25
- package/src/components/field/listbox-check.tsx +44 -0
- package/src/components/field/use-chip-row-wrap.ts +45 -0
- package/src/components/multi-select/multi-select.tsx +8 -39
- package/src/components/record-filter/chip-value.tsx +6 -5
- package/src/components/record-filter/filter-value-input.tsx +11 -9
- package/src/components/record-filter/operators.test.ts +67 -12
- package/src/components/record-filter/operators.ts +43 -2
- package/src/components/record-filter/types.ts +6 -1
- package/src/components/table/table.test.tsx +88 -9
- package/src/components/table/table.tsx +139 -13
- package/src/components/tag-input/tag-input.test.tsx +67 -1
- package/src/components/tag-input/tag-input.tsx +98 -64
- package/src/index.ts +1 -0
|
@@ -107,6 +107,14 @@ export type DataTableProps<TRow> = {
|
|
|
107
107
|
enableColumnRemoval?: boolean
|
|
108
108
|
onReorderColumn?: (params: { id: string; toIndex: number }) => void
|
|
109
109
|
onRemoveColumn?: (id: string) => void
|
|
110
|
+
/**
|
|
111
|
+
* Enable a resize handle on each column header's trailing separator.
|
|
112
|
+
* Widths are controlled: `onResizeColumn` reports the column id and its
|
|
113
|
+
* new pixel width while dragging; the parent feeds it back through the
|
|
114
|
+
* column defs' `size`. Each `ColumnDef` must carry a stable `id`.
|
|
115
|
+
*/
|
|
116
|
+
enableColumnResizing?: boolean
|
|
117
|
+
onResizeColumn?: (params: { id: string; width: number }) => void
|
|
110
118
|
/**
|
|
111
119
|
* Visual chrome around the table. `panel` (default) wraps the table in a
|
|
112
120
|
* bordered, rounded, soft-shadowed card. `bare` strips that chrome so the
|
|
@@ -135,8 +143,10 @@ export type DataTableProps<TRow> = {
|
|
|
135
143
|
*/
|
|
136
144
|
bodyBefore?: ReactNode
|
|
137
145
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
146
|
+
* Trailing row rendered directly below the last row — an Add row control at
|
|
147
|
+
* slot='end', say. Row-height, edge-to-edge, and sticky to the bottom of
|
|
148
|
+
* the scroll viewport, so it stays reachable while the end of a long list
|
|
149
|
+
* is scrolled out of view.
|
|
140
150
|
*/
|
|
141
151
|
bodyAfter?: ReactNode
|
|
142
152
|
/**
|
|
@@ -181,7 +191,7 @@ type ListItem<TRow> =
|
|
|
181
191
|
const DEFAULT_ROW_HEIGHT = 32
|
|
182
192
|
|
|
183
193
|
/** Column id of the leading checkbox column added by `selectionMode`. */
|
|
184
|
-
export const SELECTION_COLUMN_ID = '__select__'
|
|
194
|
+
export const SELECTION_COLUMN_ID = '__select__'
|
|
185
195
|
|
|
186
196
|
export function DataTable<TRow>({
|
|
187
197
|
query,
|
|
@@ -202,6 +212,8 @@ export function DataTable<TRow>({
|
|
|
202
212
|
enableColumnRemoval = false,
|
|
203
213
|
onReorderColumn,
|
|
204
214
|
onRemoveColumn,
|
|
215
|
+
enableColumnResizing = false,
|
|
216
|
+
onResizeColumn,
|
|
205
217
|
variant = 'panel',
|
|
206
218
|
rowWrapper,
|
|
207
219
|
extraRows,
|
|
@@ -457,8 +469,6 @@ export function DataTable<TRow>({
|
|
|
457
469
|
!query.isLoading && !query.error && tableRowCount === 0 && !query.isFetchingNextPage
|
|
458
470
|
const showInitialLoading = query.isLoading && tableRowCount === 0
|
|
459
471
|
const showError = !!query.error && tableRowCount === 0
|
|
460
|
-
const showEndMarker =
|
|
461
|
-
!query.isLoading && !query.isFetchingNextPage && !query.hasNextPage && tableRowCount > 0
|
|
462
472
|
|
|
463
473
|
const totalSize = virtualizer.getTotalSize()
|
|
464
474
|
const virtualItems = virtualizer.getVirtualItems()
|
|
@@ -475,7 +485,13 @@ export function DataTable<TRow>({
|
|
|
475
485
|
{/* Single both-axes scroll container. Horizontal wheel/trackpad scrolls
|
|
476
486
|
the columns; vertical scrolls the rows. The sticky header pins to the
|
|
477
487
|
top, and any `sticky left-0` gutter column freezes to the left. */}
|
|
478
|
-
<TableScroller
|
|
488
|
+
<TableScroller
|
|
489
|
+
ref={scrollerRef}
|
|
490
|
+
// Scroll padding keeps a row scrolled (or focused) into view clear of
|
|
491
|
+
// the sticky header and, when present, the sticky trailing row —
|
|
492
|
+
// otherwise a freshly added last row lands underneath the strip.
|
|
493
|
+
className={cn('scroll-pt-[36px]', bodyAfter ? 'scroll-pb-8' : undefined)}
|
|
494
|
+
>
|
|
479
495
|
{/* Content track — owns the horizontal min-width so the header + rows
|
|
480
496
|
scroll horizontally together as one unit. */}
|
|
481
497
|
<div className="flex flex-col" style={{ minWidth: minPanelWidth }}>
|
|
@@ -525,6 +541,12 @@ export function DataTable<TRow>({
|
|
|
525
541
|
{...(enableColumnRemoval && onRemoveColumn && header.column.id
|
|
526
542
|
? { onRemove: () => onRemoveColumn(header.column.id) }
|
|
527
543
|
: {})}
|
|
544
|
+
{...(enableColumnResizing && onResizeColumn && header.column.id
|
|
545
|
+
? {
|
|
546
|
+
onResize: (width: number) =>
|
|
547
|
+
onResizeColumn({ id: header.column.id, width }),
|
|
548
|
+
}
|
|
549
|
+
: {})}
|
|
528
550
|
>
|
|
529
551
|
{headerNode}
|
|
530
552
|
</TableHeaderCell>
|
|
@@ -585,18 +607,30 @@ export function DataTable<TRow>({
|
|
|
585
607
|
</div>
|
|
586
608
|
)}
|
|
587
609
|
|
|
588
|
-
{/* Sentinel — visually empty, positioned just above the loader
|
|
589
|
-
|
|
610
|
+
{/* Sentinel — visually empty, positioned just above the loader so
|
|
611
|
+
it intersects ~200 px before the bottom. */}
|
|
590
612
|
{!showEmpty && (
|
|
591
613
|
<div ref={sentinelRef} aria-hidden="true" style={{ height: 1, width: '100%' }} />
|
|
592
614
|
)}
|
|
593
615
|
|
|
594
616
|
{query.isFetchingNextPage ? <LoaderRow /> : null}
|
|
595
|
-
{showEndMarker ? <EndMarker count={tableRowCount} /> : null}
|
|
596
617
|
</div>
|
|
597
618
|
)}
|
|
598
619
|
|
|
599
|
-
{bodyAfter
|
|
620
|
+
{bodyAfter ? (
|
|
621
|
+
// Sticky to the scroller's bottom edge; `bg-bg` covers the rows
|
|
622
|
+
// sliding underneath. z-[4] — like the sticky header — stays above an
|
|
623
|
+
// editing row (z-[3]), so an editor scrolled under the strip is
|
|
624
|
+
// covered rather than drawn over it.
|
|
625
|
+
<div
|
|
626
|
+
className={cn(
|
|
627
|
+
'sticky bottom-0 z-[4] flex items-center h-8 px-0.5',
|
|
628
|
+
'border-t border-rule-2 bg-bg',
|
|
629
|
+
)}
|
|
630
|
+
>
|
|
631
|
+
{bodyAfter}
|
|
632
|
+
</div>
|
|
633
|
+
) : null}
|
|
600
634
|
</div>
|
|
601
635
|
</TableScroller>
|
|
602
636
|
</Table>
|
|
@@ -705,7 +739,7 @@ function DataRow<TRow>({
|
|
|
705
739
|
)
|
|
706
740
|
}
|
|
707
741
|
|
|
708
|
-
/* ---------- Loader
|
|
742
|
+
/* ---------- Loader ------------------------------------------------------- */
|
|
709
743
|
|
|
710
744
|
function LoaderRow() {
|
|
711
745
|
return (
|
|
@@ -732,17 +766,3 @@ function LoaderRow() {
|
|
|
732
766
|
</div>
|
|
733
767
|
)
|
|
734
768
|
}
|
|
735
|
-
|
|
736
|
-
function EndMarker({ count }: { count: number }) {
|
|
737
|
-
return (
|
|
738
|
-
<div
|
|
739
|
-
className={cn(
|
|
740
|
-
'flex items-center justify-center',
|
|
741
|
-
'h-8 font-mono text-[10.5px] uppercase tracking-[0.08em]',
|
|
742
|
-
'text-ink-4',
|
|
743
|
-
)}
|
|
744
|
-
>
|
|
745
|
-
end · {count.toLocaleString()} rows
|
|
746
|
-
</div>
|
|
747
|
-
)
|
|
748
|
-
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
ListboxCheck — the selection box at the head of a multi-select listbox
|
|
5
|
+
row. Empty box at rest, ink fill with a tick once the row is selected.
|
|
6
|
+
Reads the row's RAC `data-selected` through the `group` class, so the
|
|
7
|
+
host ListBoxItem must carry `group`. Shared by MultiSelect and
|
|
8
|
+
BadgeMultiSelect so both pickers mark selection identically.
|
|
9
|
+
========================================================================= */
|
|
10
|
+
|
|
11
|
+
export const listboxCheckStyles = tv({
|
|
12
|
+
slots: {
|
|
13
|
+
box: [
|
|
14
|
+
'relative inline-flex items-center justify-center shrink-0',
|
|
15
|
+
'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
|
|
16
|
+
'transition-[background-color,border-color] duration-150',
|
|
17
|
+
'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
|
|
18
|
+
],
|
|
19
|
+
tick: [
|
|
20
|
+
'size-3 text-surface transition-opacity duration-150',
|
|
21
|
+
'opacity-0 group-data-[selected]:opacity-100',
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export type ListboxCheckVariants = VariantProps<typeof listboxCheckStyles>
|
|
27
|
+
|
|
28
|
+
export function ListboxCheck() {
|
|
29
|
+
const { box, tick } = listboxCheckStyles()
|
|
30
|
+
return (
|
|
31
|
+
<span aria-hidden className={box()}>
|
|
32
|
+
<svg viewBox="0 0 12 12" className={tick()} aria-hidden>
|
|
33
|
+
<path
|
|
34
|
+
d="M2.5 6.25 5 8.75 9.5 3.75"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
strokeWidth="1.6"
|
|
37
|
+
strokeLinecap="round"
|
|
38
|
+
strokeLinejoin="round"
|
|
39
|
+
fill="none"
|
|
40
|
+
/>
|
|
41
|
+
</svg>
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
useChipRowWrap — does a `flex-wrap` row of chips currently need more than
|
|
5
|
+
one line? Drives the inline cell editors: they stay one row tall while
|
|
6
|
+
the chips fit, and grow into the two-row pop-out only once they wrap.
|
|
7
|
+
|
|
8
|
+
Measured from the children's boxes: a child whose top sits at or below
|
|
9
|
+
the first child's bottom is on a later line. Wrapping depends on the
|
|
10
|
+
container's width, not its height, so the answer is the same whether the
|
|
11
|
+
box is currently short (clipping) or tall. Re-measured when `signature`
|
|
12
|
+
(the chip identity) changes and whenever the container resizes. `false`
|
|
13
|
+
without layout (jsdom) and while `isEnabled` is off.
|
|
14
|
+
========================================================================= */
|
|
15
|
+
|
|
16
|
+
export function useChipRowWrap(
|
|
17
|
+
ref: RefObject<HTMLElement | null>,
|
|
18
|
+
isEnabled: boolean,
|
|
19
|
+
signature: string,
|
|
20
|
+
): boolean {
|
|
21
|
+
const [hasWrapped, setHasWrapped] = useState(false)
|
|
22
|
+
|
|
23
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `signature` (chip identity) is the re-measure trigger; it is not read inside.
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
if (!isEnabled) return
|
|
26
|
+
const element = ref.current
|
|
27
|
+
if (!element) return
|
|
28
|
+
const measure = () => {
|
|
29
|
+
const children = Array.from(element.children)
|
|
30
|
+
const first = children[0]?.getBoundingClientRect()
|
|
31
|
+
if (!first || first.height === 0) {
|
|
32
|
+
setHasWrapped(false)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
setHasWrapped(children.some((child) => child.getBoundingClientRect().top >= first.bottom - 1))
|
|
36
|
+
}
|
|
37
|
+
measure()
|
|
38
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
39
|
+
const observer = new ResizeObserver(measure)
|
|
40
|
+
observer.observe(element)
|
|
41
|
+
return () => observer.disconnect()
|
|
42
|
+
}, [ref, isEnabled, signature])
|
|
43
|
+
|
|
44
|
+
return hasWrapped
|
|
45
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
fieldMinHeight,
|
|
14
14
|
fieldTextStyles,
|
|
15
15
|
} from '../field/field'
|
|
16
|
+
import { ListboxCheck } from '../field/listbox-check'
|
|
16
17
|
|
|
17
18
|
/* =========================================================================
|
|
18
19
|
MultiSelect — pick many from a list. Trigger shows selected items as
|
|
@@ -180,7 +181,9 @@ export function MultiSelect<T extends Key>({
|
|
|
180
181
|
placement="bottom start"
|
|
181
182
|
className={cn(
|
|
182
183
|
'w-[var(--trigger-width)]',
|
|
183
|
-
|
|
184
|
+
// The popover element itself takes focus when opened programmatically
|
|
185
|
+
// (defaultOpen) — no browser outline around the whole panel.
|
|
186
|
+
'outline-none rounded-md border border-rule bg-surface p-1',
|
|
184
187
|
'shadow-[var(--shadow-raised)]',
|
|
185
188
|
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
186
189
|
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
@@ -205,6 +208,9 @@ export function MultiSelect<T extends Key>({
|
|
|
205
208
|
<ListBox
|
|
206
209
|
aria-label={typeof label === 'string' ? label : 'Options'}
|
|
207
210
|
selectionMode="multiple"
|
|
211
|
+
// RAC clears a multi-selection on Escape by default — here Escape
|
|
212
|
+
// must close the list (useOverlay) and keep the selection.
|
|
213
|
+
escapeKeyBehavior="none"
|
|
208
214
|
selectedKeys={selectedSet}
|
|
209
215
|
onSelectionChange={(keys) => {
|
|
210
216
|
if (keys === 'all') {
|
|
@@ -232,11 +238,10 @@ export function MultiSelect<T extends Key>({
|
|
|
232
238
|
'cursor-pointer outline-none select-none',
|
|
233
239
|
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
234
240
|
'data-[focused]:bg-surface-2 data-[focused]:text-ink',
|
|
235
|
-
'data-[selected]:bg-surface-3 data-[selected]:text-ink',
|
|
236
241
|
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
237
242
|
)}
|
|
238
243
|
>
|
|
239
|
-
<
|
|
244
|
+
<ListboxCheck />
|
|
240
245
|
<span className="flex-1 truncate">{option.content ?? option.label}</span>
|
|
241
246
|
</ListBoxItem>
|
|
242
247
|
))}
|
|
@@ -301,39 +306,3 @@ function Chip({
|
|
|
301
306
|
</span>
|
|
302
307
|
)
|
|
303
308
|
}
|
|
304
|
-
|
|
305
|
-
/* =========================================================================
|
|
306
|
-
Internal: empty box → indigo tick when the row is selected.
|
|
307
|
-
========================================================================= */
|
|
308
|
-
|
|
309
|
-
function Checkmark() {
|
|
310
|
-
return (
|
|
311
|
-
<span
|
|
312
|
-
aria-hidden
|
|
313
|
-
className={cn(
|
|
314
|
-
'relative inline-flex items-center justify-center shrink-0',
|
|
315
|
-
'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
|
|
316
|
-
'transition-[background-color,border-color] duration-150',
|
|
317
|
-
'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
|
|
318
|
-
)}
|
|
319
|
-
>
|
|
320
|
-
<svg
|
|
321
|
-
viewBox="0 0 12 12"
|
|
322
|
-
className={cn(
|
|
323
|
-
'size-3 text-surface transition-opacity duration-150',
|
|
324
|
-
'opacity-0 group-data-[selected]:opacity-100',
|
|
325
|
-
)}
|
|
326
|
-
aria-hidden
|
|
327
|
-
>
|
|
328
|
-
<path
|
|
329
|
-
d="M2.5 6.25 5 8.75 9.5 3.75"
|
|
330
|
-
stroke="currentColor"
|
|
331
|
-
strokeWidth="1.6"
|
|
332
|
-
strokeLinecap="round"
|
|
333
|
-
strokeLinejoin="round"
|
|
334
|
-
fill="none"
|
|
335
|
-
/>
|
|
336
|
-
</svg>
|
|
337
|
-
</span>
|
|
338
|
-
)
|
|
339
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext } from 'react'
|
|
2
2
|
import { cn } from '../../lib/cn'
|
|
3
3
|
import { Badge } from '../badge/badge'
|
|
4
|
-
import { arityOf } from './operators'
|
|
4
|
+
import { arityOf, isEnumLike } from './operators'
|
|
5
5
|
import type { ComparisonOperator, FilterField, FilterValue } from './types'
|
|
6
6
|
|
|
7
7
|
/* =========================================================================
|
|
@@ -122,8 +122,9 @@ function ChipItem({
|
|
|
122
122
|
const custom = effectiveRenderer({ field, operator, value })
|
|
123
123
|
if (custom !== undefined) return <>{custom}</>
|
|
124
124
|
}
|
|
125
|
-
// Lib default: enum → Badge with variant lookup; other → truncated
|
|
126
|
-
|
|
125
|
+
// Lib default: enum-like → Badge with variant lookup; other → truncated
|
|
126
|
+
// mono text.
|
|
127
|
+
if (isEnumLike(field)) {
|
|
127
128
|
const option = findEnumOption(field, value)
|
|
128
129
|
return (
|
|
129
130
|
<Badge
|
|
@@ -169,7 +170,7 @@ export function formatValue(
|
|
|
169
170
|
? value.split(',')
|
|
170
171
|
: []
|
|
171
172
|
if (arr.length === 0) return '—'
|
|
172
|
-
if (field
|
|
173
|
+
if (isEnumLike(field)) {
|
|
173
174
|
const labels = arr.map((v) => findEnumOption(field, v)?.label ?? v)
|
|
174
175
|
return labels.length <= 2
|
|
175
176
|
? labels.join(', ')
|
|
@@ -184,7 +185,7 @@ export function formatValue(
|
|
|
184
185
|
// badge below already do this; a single value read as the raw slug, so the
|
|
185
186
|
// same filter said "is technical_decider" or "is any of Technical Decider"
|
|
186
187
|
// depending only on the operator.
|
|
187
|
-
if (field
|
|
188
|
+
if (isEnumLike(field)) {
|
|
188
189
|
return findEnumOption(field, String(value))?.label ?? String(value)
|
|
189
190
|
}
|
|
190
191
|
if (field.type === 'number' || field.type === 'integer') {
|
|
@@ -11,7 +11,7 @@ import { Input } from '../input/input'
|
|
|
11
11
|
import { SegmentedControl } from '../segmented-control/segmented-control'
|
|
12
12
|
import { Select, type SelectOption } from '../select/select'
|
|
13
13
|
import { TagInput } from '../tag-input/tag-input'
|
|
14
|
-
import { arityOf } from './operators'
|
|
14
|
+
import { arityOf, isEnumLike } from './operators'
|
|
15
15
|
import type { ComparisonOperator, FilterField, FilterValue } from './types'
|
|
16
16
|
|
|
17
17
|
/* =========================================================================
|
|
@@ -19,11 +19,11 @@ import type { ComparisonOperator, FilterField, FilterValue } from './types'
|
|
|
19
19
|
Routes to the right primitive based on `(field.type, operator.arity)`:
|
|
20
20
|
|
|
21
21
|
arity 0 → null (no input — for empty / not_empty)
|
|
22
|
-
arity 'n' (in / not_in)
|
|
23
|
-
enum
|
|
24
|
-
other
|
|
22
|
+
arity 'n' (in / not_in / contains_all)
|
|
23
|
+
enum-like → BadgeMultiSelect (an enum, or an array of enum items)
|
|
24
|
+
other → TagInput (free-form list)
|
|
25
25
|
arity 1
|
|
26
|
-
enum
|
|
26
|
+
enum-like → Select with Badge in renderItem
|
|
27
27
|
boolean → SegmentedControl (true / false)
|
|
28
28
|
datetime → DatePicker
|
|
29
29
|
other → Input (text or number)
|
|
@@ -130,9 +130,9 @@ export function FilterValueInput({
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const leaf = (() => {
|
|
133
|
-
/* ---------- multi-value (in / not_in) ---------- */
|
|
133
|
+
/* ---------- multi-value (in / not_in / contains_all) ---------- */
|
|
134
134
|
if (arity === 'n') {
|
|
135
|
-
if (field
|
|
135
|
+
if (isEnumLike(field)) {
|
|
136
136
|
return (
|
|
137
137
|
<EnumMultiValue
|
|
138
138
|
field={field}
|
|
@@ -163,7 +163,7 @@ export function FilterValueInput({
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/* ---------- single value ---------- */
|
|
166
|
-
if (field
|
|
166
|
+
if (isEnumLike(field)) {
|
|
167
167
|
return (
|
|
168
168
|
<EnumSingleValue
|
|
169
169
|
field={field}
|
|
@@ -356,6 +356,8 @@ function placeholderFor(field: FilterField, operator: ComparisonOperator): strin
|
|
|
356
356
|
if (field.type === 'number' || field.type === 'integer' || field.type === 'currency') return '0'
|
|
357
357
|
if (operator === 'starts_with') return 'starts with…'
|
|
358
358
|
if (operator === 'ends_with') return 'ends with…'
|
|
359
|
-
if (operator === 'contains') return 'contains…'
|
|
359
|
+
if (operator === 'contains') return field.type === 'array' ? 'has…' : 'contains…'
|
|
360
|
+
if (operator === 'not_contains')
|
|
361
|
+
return field.type === 'array' ? 'does not have…' : 'does not contain…'
|
|
360
362
|
return 'value'
|
|
361
363
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
arityOf,
|
|
5
|
+
defaultValueFor,
|
|
6
|
+
isEnumLike,
|
|
7
|
+
OPERATORS,
|
|
8
|
+
operatorById,
|
|
9
|
+
operatorsForType,
|
|
10
|
+
} from './operators'
|
|
4
11
|
|
|
5
12
|
describe('operatorsForType', () => {
|
|
6
13
|
it('includes eq/ne for every primitive type', () => {
|
|
@@ -36,16 +43,32 @@ describe('operatorsForType', () => {
|
|
|
36
43
|
expect(ops).not.toContain('gt')
|
|
37
44
|
})
|
|
38
45
|
|
|
39
|
-
it('allows in / not_in on strings, numbers, and
|
|
46
|
+
it('allows in / not_in on strings, numbers, enums and arrays (not datetime/boolean)', () => {
|
|
40
47
|
for (const op of ['in', 'not_in'] as const) {
|
|
41
48
|
const types = new Set(OPERATORS.find((o) => o.id === op)?.types ?? [])
|
|
42
49
|
expect(types.has('string')).toBe(true)
|
|
43
50
|
expect(types.has('number')).toBe(true)
|
|
44
51
|
expect(types.has('integer')).toBe(true)
|
|
45
52
|
expect(types.has('enum')).toBe(true)
|
|
53
|
+
expect(types.has('array')).toBe(true)
|
|
46
54
|
expect(types.has('datetime')).toBe(false)
|
|
47
55
|
expect(types.has('boolean')).toBe(false)
|
|
48
|
-
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('allows not_contains wherever contains is allowed', () => {
|
|
60
|
+
expect(OPERATORS.find((o) => o.id === 'not_contains')?.types).toEqual(
|
|
61
|
+
OPERATORS.find((o) => o.id === 'contains')?.types,
|
|
62
|
+
)
|
|
63
|
+
expect(operatorsForType('string').find((o) => o.id === 'not_contains')?.label).toBe(
|
|
64
|
+
'does not contain',
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('allows contains_all on arrays only', () => {
|
|
69
|
+
expect(OPERATORS.find((o) => o.id === 'contains_all')?.types).toEqual(['array'])
|
|
70
|
+
for (const t of ['string', 'number', 'enum', 'contact-address'] as const) {
|
|
71
|
+
expect(operatorsForType(t).map((o) => o.id)).not.toContain('contains_all')
|
|
49
72
|
}
|
|
50
73
|
})
|
|
51
74
|
|
|
@@ -56,14 +79,32 @@ describe('operatorsForType', () => {
|
|
|
56
79
|
}
|
|
57
80
|
})
|
|
58
81
|
|
|
59
|
-
it('
|
|
60
|
-
const arrayOps = operatorsForType('array')
|
|
61
|
-
expect(arrayOps).
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
it('offers exactly the set-membership operators on arrays, positive-first', () => {
|
|
83
|
+
const arrayOps = operatorsForType('array')
|
|
84
|
+
expect(arrayOps.map((o) => o.id)).toEqual([
|
|
85
|
+
'contains',
|
|
86
|
+
'not_contains',
|
|
87
|
+
'in',
|
|
88
|
+
'contains_all',
|
|
89
|
+
'not_in',
|
|
90
|
+
'empty',
|
|
91
|
+
'not_empty',
|
|
92
|
+
])
|
|
93
|
+
// Labelled in the words every CRM uses for membership.
|
|
94
|
+
expect(arrayOps.map((o) => o.label)).toEqual([
|
|
95
|
+
'has',
|
|
96
|
+
'does not have',
|
|
97
|
+
'has any of',
|
|
98
|
+
'has all of',
|
|
99
|
+
'has none of',
|
|
100
|
+
'is empty',
|
|
101
|
+
'is not empty',
|
|
102
|
+
])
|
|
103
|
+
// No magnitude / equality / text-shape operators on arrays.
|
|
104
|
+
const ids = arrayOps.map((o) => o.id)
|
|
105
|
+
expect(ids).not.toContain('eq')
|
|
106
|
+
expect(ids).not.toContain('gt')
|
|
107
|
+
expect(ids).not.toContain('starts_with')
|
|
67
108
|
})
|
|
68
109
|
|
|
69
110
|
it('allows identity operators on user, not text/numeric shapes', () => {
|
|
@@ -90,17 +131,31 @@ describe('arityOf', () => {
|
|
|
90
131
|
expect(arityOf('empty')).toBe(0)
|
|
91
132
|
expect(arityOf('not_empty')).toBe(0)
|
|
92
133
|
})
|
|
93
|
-
it("returns 'n' for in/not_in", () => {
|
|
134
|
+
it("returns 'n' for in/not_in/contains_all", () => {
|
|
94
135
|
expect(arityOf('in')).toBe('n')
|
|
95
136
|
expect(arityOf('not_in')).toBe('n')
|
|
137
|
+
expect(arityOf('contains_all')).toBe('n')
|
|
96
138
|
})
|
|
97
139
|
it('returns 1 for the rest', () => {
|
|
98
140
|
expect(arityOf('eq')).toBe(1)
|
|
99
141
|
expect(arityOf('contains')).toBe(1)
|
|
142
|
+
expect(arityOf('not_contains')).toBe(1)
|
|
100
143
|
expect(arityOf('gt')).toBe(1)
|
|
101
144
|
})
|
|
102
145
|
})
|
|
103
146
|
|
|
147
|
+
describe('isEnumLike', () => {
|
|
148
|
+
it('is true for an enum and for an array carrying item options', () => {
|
|
149
|
+
expect(isEnumLike({ type: 'enum', options: [] })).toBe(true)
|
|
150
|
+
expect(isEnumLike({ type: 'array', options: [{ value: 'a', label: 'A' }] })).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
it('is false for a free-text array and for scalars', () => {
|
|
153
|
+
expect(isEnumLike({ type: 'array' })).toBe(false)
|
|
154
|
+
expect(isEnumLike({ type: 'array', options: [] })).toBe(false)
|
|
155
|
+
expect(isEnumLike({ type: 'string' })).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
104
159
|
describe('defaultValueFor', () => {
|
|
105
160
|
it('returns null for arity 0', () => {
|
|
106
161
|
expect(defaultValueFor('string', 0)).toBeNull()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ComparisonOperator,
|
|
3
|
+
FilterField,
|
|
3
4
|
FilterFieldType,
|
|
4
5
|
FilterValue,
|
|
5
6
|
OperatorArity,
|
|
@@ -64,6 +65,7 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
|
|
|
64
65
|
'user',
|
|
65
66
|
'principal',
|
|
66
67
|
'contact-address',
|
|
68
|
+
'array',
|
|
67
69
|
],
|
|
68
70
|
},
|
|
69
71
|
{
|
|
@@ -80,9 +82,18 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
|
|
|
80
82
|
'user',
|
|
81
83
|
'principal',
|
|
82
84
|
'contact-address',
|
|
85
|
+
'array',
|
|
83
86
|
],
|
|
84
87
|
},
|
|
85
88
|
{ id: 'contains', label: 'contains', arity: 1, types: ['string', 'array', 'contact-address'] },
|
|
89
|
+
{
|
|
90
|
+
id: 'not_contains',
|
|
91
|
+
label: 'does not contain',
|
|
92
|
+
arity: 1,
|
|
93
|
+
types: ['string', 'array', 'contact-address'],
|
|
94
|
+
},
|
|
95
|
+
// Array-only: "has every one of these". On a scalar the backend rejects it.
|
|
96
|
+
{ id: 'contains_all', label: 'has all of', arity: 'n', types: ['array'] },
|
|
86
97
|
{ id: 'starts_with', label: 'starts with', arity: 1, types: ['string', 'contact-address'] },
|
|
87
98
|
{ id: 'ends_with', label: 'ends with', arity: 1, types: ['string', 'contact-address'] },
|
|
88
99
|
{
|
|
@@ -148,15 +159,45 @@ const OP_LABEL_OVERRIDES: Partial<
|
|
|
148
159
|
integer: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
|
|
149
160
|
currency: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
|
|
150
161
|
datetime: { eq: 'is on', ne: 'is not on', gt: 'is after', lt: 'is before' },
|
|
151
|
-
|
|
162
|
+
// Arrays filter by set membership, in the words every CRM uses for it.
|
|
163
|
+
array: {
|
|
164
|
+
contains: 'has',
|
|
165
|
+
not_contains: 'does not have',
|
|
166
|
+
in: 'has any of',
|
|
167
|
+
not_in: 'has none of',
|
|
168
|
+
contains_all: 'has all of',
|
|
169
|
+
empty: 'is empty',
|
|
170
|
+
not_empty: 'is not empty',
|
|
171
|
+
},
|
|
152
172
|
boolean: { eq: 'is', ne: 'is not' },
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/* Per-type picker order where the catalogue's global order reads wrong.
|
|
176
|
+
Arrays go positive-first, single value before list, so the dropdown reads
|
|
177
|
+
has / does not have / has any of / has all of / has none of. */
|
|
178
|
+
const OP_ORDER_OVERRIDES: Partial<Record<FilterFieldType, ReadonlyArray<ComparisonOperator>>> = {
|
|
179
|
+
array: ['contains', 'not_contains', 'in', 'contains_all', 'not_in', 'empty', 'not_empty'],
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
export function operatorsForType(type: FilterFieldType): ReadonlyArray<OperatorDescriptor> {
|
|
156
|
-
|
|
183
|
+
const ops = OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
|
|
157
184
|
const override = OP_LABEL_OVERRIDES[type]?.[op.id]
|
|
158
185
|
return override ? { ...op, label: override } : op
|
|
159
186
|
})
|
|
187
|
+
const order = OP_ORDER_OVERRIDES[type]
|
|
188
|
+
if (!order) return ops
|
|
189
|
+
return [...ops].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* True when the field's values come from a declared option list — an enum,
|
|
194
|
+
* or an array whose ITEMS are an enum. The value editor and the chip render
|
|
195
|
+
* these from `field.options` (Select / BadgeMultiSelect / Badge) rather than
|
|
196
|
+
* as free text.
|
|
197
|
+
*/
|
|
198
|
+
export function isEnumLike(field: Pick<FilterField, 'type' | 'options'>): boolean {
|
|
199
|
+
if (field.type === 'enum') return true
|
|
200
|
+
return field.type === 'array' && (field.options?.length ?? 0) > 0
|
|
160
201
|
}
|
|
161
202
|
|
|
162
203
|
export function operatorById(id: ComparisonOperator): OperatorDescriptor | undefined {
|
|
@@ -42,7 +42,10 @@ export type FilterField = {
|
|
|
42
42
|
/** Parent label for breadcrumb in chips, e.g. `'Owner'`. Omitted for top-level. */
|
|
43
43
|
parentLabel?: string
|
|
44
44
|
type: FilterFieldType
|
|
45
|
-
/** Required when `type === 'enum'
|
|
45
|
+
/** Required when `type === 'enum'`. For `type === 'array'` these are the
|
|
46
|
+
* ITEM enum's values (an array of enum items), so the membership editors
|
|
47
|
+
* and chips pick from the same list the record editor does. Ignored
|
|
48
|
+
* otherwise. */
|
|
46
49
|
options?: ReadonlyArray<FilterFieldOption>
|
|
47
50
|
/** Hint for `<Input type=…>`. Defaults derived from `type`. */
|
|
48
51
|
inputType?: 'text' | 'number' | 'date' | 'datetime' | 'email'
|
|
@@ -111,6 +114,8 @@ export type ComparisonOperator =
|
|
|
111
114
|
| 'in'
|
|
112
115
|
| 'not_in'
|
|
113
116
|
| 'contains'
|
|
117
|
+
| 'not_contains'
|
|
118
|
+
| 'contains_all'
|
|
114
119
|
| 'starts_with'
|
|
115
120
|
| 'ends_with'
|
|
116
121
|
| 'empty'
|