@proteos/ui 0.55.0 → 0.57.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/{chunk-IKAX76MB.js → chunk-NSE4VW3Z.js} +45 -21
- package/dist/chunk-NSE4VW3Z.js.map +1 -0
- package/dist/{chunk-MWAXK66H.cjs → chunk-NWGG5WFL.cjs} +45 -21
- package/dist/chunk-NWGG5WFL.cjs.map +1 -0
- package/dist/index.cjs +377 -361
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -14
- package/dist/index.d.ts +46 -14
- package/dist/index.js +124 -108
- package/dist/index.js.map +1 -1
- package/dist/{pdf-view-MH75RVQP.js → pdf-view-RQ2OZYHJ.js} +3 -3
- package/dist/{pdf-view-MH75RVQP.js.map → pdf-view-RQ2OZYHJ.js.map} +1 -1
- package/dist/{pdf-view-K4MMVHVY.cjs → pdf-view-RRMAUK3L.cjs} +7 -7
- package/dist/{pdf-view-K4MMVHVY.cjs.map → pdf-view-RRMAUK3L.cjs.map} +1 -1
- package/package.json +2 -2
- package/src/components/avatar/avatar.stories.tsx +13 -0
- package/src/components/avatar/avatar.tsx +16 -9
- package/src/components/badge-multi-select/badge-multi-select.tsx +2 -2
- package/src/components/button/button.stories.tsx +202 -97
- package/src/components/button/button.test.tsx +40 -7
- package/src/components/button/button.tsx +71 -18
- package/src/components/combobox/combobox.tsx +2 -2
- package/src/components/currency-input/currency-input.tsx +2 -2
- package/src/components/date-picker/calendar-internals.tsx +1 -1
- package/src/components/date-picker/date-picker.tsx +3 -3
- package/src/components/date-time-picker/date-time-picker.tsx +3 -3
- package/src/components/icon-picker/icon-picker.tsx +2 -2
- package/src/components/image-lightbox/image-lightbox.tsx +2 -2
- package/src/components/menu/menu.tsx +2 -2
- package/src/components/modal/modal.tsx +4 -4
- package/src/components/multi-select/multi-select.tsx +2 -2
- package/src/components/popover/popover.tsx +2 -2
- 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/nested-group.tsx +2 -2
- package/src/components/record-filter/operators.test.ts +67 -12
- package/src/components/record-filter/operators.ts +43 -2
- package/src/components/record-filter/record-filter-builder-panel.tsx +11 -23
- package/src/components/record-filter/record-filter-chips.tsx +3 -10
- package/src/components/record-filter/types.ts +6 -1
- package/src/components/segmented-control/segmented-control.stories.tsx +134 -8
- package/src/components/segmented-control/segmented-control.test.tsx +81 -8
- package/src/components/segmented-control/segmented-control.tsx +38 -19
- package/src/components/select/select.tsx +2 -2
- package/src/components/time-field/time-field.tsx +1 -1
- package/src/components/toast/toast.tsx +2 -2
- package/src/components/tooltip/tooltip.tsx +2 -2
- package/src/styles.css +58 -0
- package/dist/chunk-IKAX76MB.js.map +0 -1
- package/dist/chunk-MWAXK66H.cjs.map +0 -1
|
@@ -157,8 +157,8 @@ export function CurrencyInput({
|
|
|
157
157
|
className={cn(
|
|
158
158
|
'min-w-[9rem] rounded-md border border-rule bg-surface p-1',
|
|
159
159
|
'shadow-[var(--shadow-raised)]',
|
|
160
|
-
'entering:animate-
|
|
161
|
-
'exiting:animate-
|
|
160
|
+
'entering:animate-enter-slide-down',
|
|
161
|
+
'exiting:animate-exit-slide-up',
|
|
162
162
|
)}
|
|
163
163
|
>
|
|
164
164
|
<ListBox className="max-h-72 overflow-auto outline-none">
|
|
@@ -124,7 +124,7 @@ export function CalendarSurface() {
|
|
|
124
124
|
'data-[outside-month]:text-ink-4',
|
|
125
125
|
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
126
126
|
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
127
|
-
'data-[selected]:bg-accent data-[selected]:text-
|
|
127
|
+
'data-[selected]:bg-accent data-[selected]:text-tile-fg',
|
|
128
128
|
'data-[selected]:data-[hovered]:bg-accent-2',
|
|
129
129
|
'data-[disabled]:opacity-30 data-[disabled]:cursor-not-allowed',
|
|
130
130
|
'data-[unavailable]:line-through data-[unavailable]:text-ink-4',
|
|
@@ -86,7 +86,7 @@ export function DatePicker<T extends DateValue>({
|
|
|
86
86
|
'data-[placeholder]:text-ink-4',
|
|
87
87
|
'data-[type=literal]:text-ink-4',
|
|
88
88
|
// segment focus treatment: indigo fill, paper text
|
|
89
|
-
'data-[focused]:bg-accent data-[focused]:text-
|
|
89
|
+
'data-[focused]:bg-accent data-[focused]:text-tile-fg data-[focused]:rounded-[3px]',
|
|
90
90
|
'data-[disabled]:cursor-not-allowed',
|
|
91
91
|
)}
|
|
92
92
|
/>
|
|
@@ -127,8 +127,8 @@ export function DatePicker<T extends DateValue>({
|
|
|
127
127
|
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
128
128
|
'rounded-md border border-rule bg-surface p-4',
|
|
129
129
|
'shadow-[var(--shadow-raised)]',
|
|
130
|
-
'entering:animate-
|
|
131
|
-
'exiting:animate-
|
|
130
|
+
'entering:animate-enter-slide-down',
|
|
131
|
+
'exiting:animate-exit-slide-up',
|
|
132
132
|
)}
|
|
133
133
|
>
|
|
134
134
|
<Dialog className="outline-none">
|
|
@@ -43,7 +43,7 @@ const segmentStyles = cn(
|
|
|
43
43
|
'data-[placeholder]:text-ink-4',
|
|
44
44
|
'data-[type=literal]:text-ink-4',
|
|
45
45
|
// segment focus treatment: indigo fill, paper text
|
|
46
|
-
'data-[focused]:bg-accent data-[focused]:text-
|
|
46
|
+
'data-[focused]:bg-accent data-[focused]:text-tile-fg data-[focused]:rounded-[3px]',
|
|
47
47
|
'data-[disabled]:cursor-not-allowed',
|
|
48
48
|
)
|
|
49
49
|
|
|
@@ -185,8 +185,8 @@ export function DateTimePicker<T extends DateValue>({
|
|
|
185
185
|
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
186
186
|
'rounded-md border border-rule bg-surface p-4',
|
|
187
187
|
'shadow-[var(--shadow-raised)]',
|
|
188
|
-
'entering:animate-
|
|
189
|
-
'exiting:animate-
|
|
188
|
+
'entering:animate-enter-slide-down',
|
|
189
|
+
'exiting:animate-exit-slide-up',
|
|
190
190
|
)}
|
|
191
191
|
>
|
|
192
192
|
<Dialog className="outline-none">
|
|
@@ -227,8 +227,8 @@ export function IconPicker({
|
|
|
227
227
|
className={cn(
|
|
228
228
|
'w-[316px] rounded-md border border-rule bg-surface p-2',
|
|
229
229
|
'shadow-[var(--shadow-raised)]',
|
|
230
|
-
'entering:animate-
|
|
231
|
-
'exiting:animate-
|
|
230
|
+
'entering:animate-enter-slide-down',
|
|
231
|
+
'exiting:animate-exit-slide-up',
|
|
232
232
|
)}
|
|
233
233
|
>
|
|
234
234
|
<div className="flex items-center gap-1.5 pb-2">
|
|
@@ -23,8 +23,8 @@ export const imageLightboxStyles = tv({
|
|
|
23
23
|
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
|
24
24
|
// Deeper scrim than Modal's 40% — the image needs the page fully dimmed.
|
|
25
25
|
'bg-[color-mix(in_oklab,var(--color-ink)_72%,transparent)]',
|
|
26
|
-
'entering:animate-
|
|
27
|
-
'exiting:animate-
|
|
26
|
+
'entering:animate-enter-fade',
|
|
27
|
+
'exiting:animate-exit-fade',
|
|
28
28
|
],
|
|
29
29
|
})
|
|
30
30
|
|
|
@@ -61,8 +61,8 @@ export function MenuPopover({ className, offset = 4, placement = 'bottom start',
|
|
|
61
61
|
cn(
|
|
62
62
|
'min-w-[200px] p-1 bg-surface border border-rule rounded-md',
|
|
63
63
|
'shadow-[var(--shadow-float)] outline-none z-[1200]',
|
|
64
|
-
'entering:animate-
|
|
65
|
-
'exiting:animate-
|
|
64
|
+
'entering:animate-enter-zoom',
|
|
65
|
+
'exiting:animate-exit-zoom',
|
|
66
66
|
typeof className === 'function' ? className(renderProps) : className,
|
|
67
67
|
)
|
|
68
68
|
}
|
|
@@ -67,8 +67,8 @@ export function ModalContent({
|
|
|
67
67
|
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
|
68
68
|
// Warm ink scrim — stays in the Proteos palette (no pure black).
|
|
69
69
|
'bg-[color-mix(in_oklab,var(--color-ink)_40%,transparent)]',
|
|
70
|
-
'entering:animate-
|
|
71
|
-
'exiting:animate-
|
|
70
|
+
'entering:animate-enter-fade',
|
|
71
|
+
'exiting:animate-exit-fade',
|
|
72
72
|
typeof className === 'function' ? className(renderProps) : className,
|
|
73
73
|
)
|
|
74
74
|
}
|
|
@@ -82,8 +82,8 @@ export function ModalContent({
|
|
|
82
82
|
'flex flex-col max-h-[calc(100dvh-2rem)]',
|
|
83
83
|
'bg-surface border border-rule rounded-lg',
|
|
84
84
|
'shadow-[var(--shadow-float)]',
|
|
85
|
-
'entering:animate-
|
|
86
|
-
'exiting:animate-
|
|
85
|
+
'entering:animate-enter-zoom',
|
|
86
|
+
'exiting:animate-exit-zoom',
|
|
87
87
|
modalClassName,
|
|
88
88
|
)}
|
|
89
89
|
>
|
|
@@ -185,8 +185,8 @@ export function MultiSelect<T extends Key>({
|
|
|
185
185
|
// (defaultOpen) — no browser outline around the whole panel.
|
|
186
186
|
'outline-none rounded-md border border-rule bg-surface p-1',
|
|
187
187
|
'shadow-[var(--shadow-raised)]',
|
|
188
|
-
'entering:animate-
|
|
189
|
-
'exiting:animate-
|
|
188
|
+
'entering:animate-enter-slide-down',
|
|
189
|
+
'exiting:animate-exit-slide-up',
|
|
190
190
|
)}
|
|
191
191
|
>
|
|
192
192
|
{isSearchable ? (
|
|
@@ -64,8 +64,8 @@ export function PopoverContent({
|
|
|
64
64
|
cn(
|
|
65
65
|
'rounded-md border border-rule bg-surface',
|
|
66
66
|
'shadow-[var(--shadow-float)]',
|
|
67
|
-
'entering:animate-
|
|
68
|
-
'exiting:animate-
|
|
67
|
+
'entering:animate-enter-slide-down',
|
|
68
|
+
'exiting:animate-exit-slide-up',
|
|
69
69
|
panelClassName,
|
|
70
70
|
typeof className === 'function' ? className(renderProps) : className,
|
|
71
71
|
)
|
|
@@ -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
|
}
|
|
@@ -84,8 +84,8 @@ export function NestedGroup({
|
|
|
84
84
|
<SegmentedControl<LogicalOperator>
|
|
85
85
|
aria-label="Group joiner"
|
|
86
86
|
options={[
|
|
87
|
-
{ id: 'and', label: '
|
|
88
|
-
{ id: 'or', label: '
|
|
87
|
+
{ id: 'and', label: 'and' },
|
|
88
|
+
{ id: 'or', label: 'or' },
|
|
89
89
|
]}
|
|
90
90
|
value={group.logicalOperator}
|
|
91
91
|
onChange={(next) => onChange({ ...group, logicalOperator: next })}
|
|
@@ -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 {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cn } from '../../lib/cn'
|
|
2
|
+
import { Button } from '../button/button'
|
|
2
3
|
import { SegmentedControl } from '../segmented-control/segmented-control'
|
|
3
4
|
import { type FilterValueInputRenderer, RenderValueInputProvider } from './filter-value-input'
|
|
4
5
|
import { RecordFilterEditor } from './record-filter-editor'
|
|
@@ -86,8 +87,8 @@ export function RecordFilterBuilderPanel({
|
|
|
86
87
|
<SegmentedControl<LogicalOperator>
|
|
87
88
|
aria-label="Joiner"
|
|
88
89
|
options={[
|
|
89
|
-
{ id: 'and', label: '
|
|
90
|
-
{ id: 'or', label: '
|
|
90
|
+
{ id: 'and', label: 'and' },
|
|
91
|
+
{ id: 'or', label: 'or' },
|
|
91
92
|
]}
|
|
92
93
|
value={filter.logicalOperator}
|
|
93
94
|
onChange={setJoiner}
|
|
@@ -190,16 +191,11 @@ export function RecordFilterBuilderPanel({
|
|
|
190
191
|
</button>
|
|
191
192
|
<div className="flex items-center gap-1.5">
|
|
192
193
|
{onSaveView ? (
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-sm',
|
|
199
|
-
'font-sans text-[13px] text-ink-2 hover:bg-surface-2 hover:text-ink',
|
|
200
|
-
'disabled:opacity-40 disabled:pointer-events-none',
|
|
201
|
-
'outline-none cursor-pointer',
|
|
202
|
-
)}
|
|
194
|
+
<Button
|
|
195
|
+
variant="ghost"
|
|
196
|
+
size="sm"
|
|
197
|
+
onPress={onSaveView}
|
|
198
|
+
isDisabled={totalChildren === 0}
|
|
203
199
|
>
|
|
204
200
|
<svg
|
|
205
201
|
aria-hidden
|
|
@@ -215,19 +211,11 @@ export function RecordFilterBuilderPanel({
|
|
|
215
211
|
<path d="M5 2v3h4V2" />
|
|
216
212
|
</svg>
|
|
217
213
|
Save as view
|
|
218
|
-
</
|
|
214
|
+
</Button>
|
|
219
215
|
) : null}
|
|
220
|
-
<
|
|
221
|
-
type="button"
|
|
222
|
-
onClick={onClose}
|
|
223
|
-
className={cn(
|
|
224
|
-
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-sm',
|
|
225
|
-
'bg-ink text-surface font-mono text-[12px]',
|
|
226
|
-
'hover:bg-accent-ink outline-none cursor-pointer',
|
|
227
|
-
)}
|
|
228
|
-
>
|
|
216
|
+
<Button variant="cta" size="sm" onPress={onClose}>
|
|
229
217
|
Done
|
|
230
|
-
</
|
|
218
|
+
</Button>
|
|
231
219
|
</div>
|
|
232
220
|
</div>
|
|
233
221
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { cn } from '../../lib/cn'
|
|
3
|
+
import { Button } from '../button/button'
|
|
3
4
|
import { PopoverContent } from '../popover/popover'
|
|
4
5
|
import { AttributePicker } from './attribute-picker'
|
|
5
6
|
import { ChipValue, type ChipValueItemRenderer } from './chip-value'
|
|
@@ -489,17 +490,9 @@ function ChipMiniEditor({
|
|
|
489
490
|
onChange={onChangeValue}
|
|
490
491
|
/>
|
|
491
492
|
</div>
|
|
492
|
-
<
|
|
493
|
-
type="submit"
|
|
494
|
-
className={cn(
|
|
495
|
-
'shrink-0 inline-flex items-center px-2.5 py-1.5 rounded-md',
|
|
496
|
-
'bg-ink text-surface font-mono text-[12px]',
|
|
497
|
-
'hover:bg-accent-ink cursor-pointer outline-none',
|
|
498
|
-
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
499
|
-
)}
|
|
500
|
-
>
|
|
493
|
+
<Button type="submit" variant="cta" size="sm" className="shrink-0">
|
|
501
494
|
Save
|
|
502
|
-
</
|
|
495
|
+
</Button>
|
|
503
496
|
</form>
|
|
504
497
|
)
|
|
505
498
|
}
|
|
@@ -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'
|