@proteos/ui 0.55.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -56,7 +56,7 @@
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
58
  "thinking-orbs": "^0.3.1",
59
- "@proteos/sdk": "0.55.0"
59
+ "@proteos/sdk": "0.56.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@biomejs/biome": "^2.4.13",
@@ -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 mono text.
126
- if (field.type === 'enum') {
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.type === 'enum') {
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.type === 'enum') {
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 → BadgeMultiSelect
24
- other → TagInput (free-form list)
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 → Select with Badge in renderItem
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.type === 'enum') {
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.type === 'enum') {
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 { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
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 enums (not datetime/boolean/array)', () => {
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
- expect(types.has('array')).toBe(false)
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('allows contains/empty/not_empty on arrays', () => {
60
- const arrayOps = operatorsForType('array').map((o) => o.id)
61
- expect(arrayOps).toContain('contains')
62
- expect(arrayOps).toContain('empty')
63
- expect(arrayOps).toContain('not_empty')
64
- // No magnitude/text-shape operators on arrays.
65
- expect(arrayOps).not.toContain('gt')
66
- expect(arrayOps).not.toContain('starts_with')
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
- array: { contains: 'contains', empty: 'is empty', not_empty: 'is not empty' },
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
- return OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
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'`; ignored otherwise. */
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'