@startsimpli/ui 0.4.34 → 0.4.36

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": "@startsimpli/ui",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -18,21 +18,33 @@ import { notify as defaultNotify } from '../toast'
18
18
  import { cn } from '../../lib/utils'
19
19
  import {
20
20
  type DataType,
21
- type DraftAttribute,
21
+ type EditableDraft,
22
+ type ExistingAttribute,
22
23
  type AttributePayload,
24
+ type SchemaId,
23
25
  DATA_TYPES,
24
26
  blankAttribute,
25
27
  toKey,
26
28
  draftToAttributePayloads,
29
+ attributeToDraft,
30
+ diffAttributes,
27
31
  } from './schema-builder-core'
28
32
 
29
33
  /** The type the backend echoes back after creation (only `id` is required). */
30
34
  export interface CreatedType {
31
- id: number
35
+ id: SchemaId
32
36
  key: string
33
37
  label: string
34
38
  }
35
39
 
40
+ /** A persisted type loaded into the builder for editing. */
41
+ export interface ExistingType {
42
+ id: SchemaId
43
+ key: string
44
+ label: string
45
+ attributes: ExistingAttribute[]
46
+ }
47
+
36
48
  /** A minimal notifier surface; defaults to @startsimpli/ui's toast notify. */
37
49
  export interface SchemaTypeBuilderNotifier {
38
50
  success?: (message: string) => void
@@ -49,41 +61,73 @@ export interface SchemaTypeBuilderClassNames {
49
61
 
50
62
  export interface SchemaTypeBuilderProps {
51
63
  /** Persist the entity type. Must resolve to at least its server-assigned id. */
52
- createType: (input: { key: string; label: string }) => Promise<{ id: number }>
53
- /** Persist one attribute against the just-created type. */
64
+ createType: (input: { key: string; label: string }) => Promise<{ id: SchemaId }>
65
+ /** Persist one attribute against a type. */
54
66
  createAttribute: (input: AttributePayload) => Promise<unknown>
55
- /** Fired after the type and all its attributes are created (navigate / refetch here). */
67
+ /** Fired after the type + its attributes are persisted (create OR save). Navigate / refetch here. */
56
68
  onCreated?: (created: CreatedType) => void
69
+
70
+ // ---- edit mode (optional): pass an existing type to load + edit it ----
71
+ /** When present, the builder opens in edit mode pre-filled from this type. */
72
+ existingType?: ExistingType
73
+ /** Rename a type's label (key is immutable). Required for label edits. */
74
+ updateType?: (id: SchemaId, input: { label: string }) => Promise<unknown>
75
+ /** Patch an existing attribute. Required for field edits in edit mode. */
76
+ updateAttribute?: (
77
+ id: SchemaId,
78
+ input: Omit<AttributePayload, 'entityType'>,
79
+ ) => Promise<unknown>
80
+ /** Delete an existing attribute. Required to remove fields in edit mode. */
81
+ deleteAttribute?: (id: SchemaId) => Promise<unknown>
82
+ /** Delete the whole type. When provided in edit mode, a delete control is shown. */
83
+ deleteType?: (id: SchemaId) => Promise<unknown>
84
+ /** Fired after the type is deleted (navigate away / refetch here). */
85
+ onDeleted?: (type: ExistingType) => void
86
+
57
87
  /** Inject a notifier; defaults to the shared @startsimpli/ui notify. */
58
88
  notify?: SchemaTypeBuilderNotifier
59
- /** Override the submit label (default "Create type"). */
89
+ /** Override the create-mode submit label (default "Create type"). */
60
90
  submitLabel?: string
61
91
  classNames?: SchemaTypeBuilderClassNames
62
92
  }
63
93
 
94
+ function initialDrafts(existingType?: ExistingType): EditableDraft[] {
95
+ if (existingType && existingType.attributes.length) {
96
+ return existingType.attributes.map(attributeToDraft)
97
+ }
98
+ return [blankAttribute()]
99
+ }
100
+
64
101
  /**
65
102
  * No-code entity-type builder, promoted from frontend-starter (rule #9). The
66
- * persistence layer is injected via `createType`/`createAttribute` props so the
67
- * component carries no API client, router, or query-cache coupling — the
68
- * consuming app wires those in a thin wrapper and handles post-create
69
- * navigation/refetch in `onCreated`.
103
+ * persistence layer is injected via callback props so the component carries no
104
+ * API client, router, or query-cache coupling. Pass `existingType` (+ the
105
+ * update/delete callbacks) to open it in edit mode; otherwise it creates.
70
106
  */
71
107
  export function SchemaTypeBuilder({
72
108
  createType,
73
109
  createAttribute,
74
110
  onCreated,
111
+ existingType,
112
+ updateType,
113
+ updateAttribute,
114
+ deleteAttribute,
115
+ deleteType,
116
+ onDeleted,
75
117
  notify = defaultNotify,
76
118
  submitLabel = 'Create type',
77
119
  classNames,
78
120
  }: SchemaTypeBuilderProps) {
79
- const [label, setLabel] = useState('')
121
+ const editing = !!existingType
122
+ const [label, setLabel] = useState(existingType?.label ?? '')
80
123
  const [keyOverride, setKeyOverride] = useState('')
81
- const [attributes, setAttributes] = useState<DraftAttribute[]>([blankAttribute()])
124
+ const [attributes, setAttributes] = useState<EditableDraft[]>(() => initialDrafts(existingType))
82
125
  const [saving, setSaving] = useState(false)
126
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
83
127
 
84
- const effectiveKey = keyOverride.trim() || toKey(label)
128
+ const effectiveKey = editing ? existingType!.key : keyOverride.trim() || toKey(label)
85
129
 
86
- function updateAttr(index: number, patch: Partial<DraftAttribute>) {
130
+ function updateAttr(index: number, patch: Partial<EditableDraft>) {
87
131
  setAttributes((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)))
88
132
  }
89
133
 
@@ -95,6 +139,32 @@ export function SchemaTypeBuilder({
95
139
  setAttributes((prev) => prev.filter((_, i) => i !== index))
96
140
  }
97
141
 
142
+ async function persistEdit() {
143
+ const type = existingType!
144
+ if (updateType && label.trim() !== type.label) {
145
+ await updateType(type.id, { label: label.trim() })
146
+ }
147
+ const { creates, updates, deletes } = diffAttributes(type.id, type.attributes, attributes)
148
+ for (const payload of creates) await createAttribute(payload)
149
+ for (const u of updates) {
150
+ const { id, ...rest } = u
151
+ if (updateAttribute) await updateAttribute(id, rest)
152
+ }
153
+ for (const id of deletes) {
154
+ if (deleteAttribute) await deleteAttribute(id)
155
+ }
156
+ }
157
+
158
+ async function persistCreate() {
159
+ const type = await createType({ key: effectiveKey, label: label.trim() })
160
+ // Create each declared attribute, in order; blank rows + config are handled
161
+ // by the pure helper. (New attributes carry no id in edit mode either.)
162
+ for (const payload of draftToAttributePayloads(type.id, attributes)) {
163
+ await createAttribute(payload)
164
+ }
165
+ return type.id
166
+ }
167
+
98
168
  async function handleSubmit(e: React.FormEvent) {
99
169
  e.preventDefault()
100
170
  if (!label.trim() || !effectiveKey) {
@@ -103,20 +173,34 @@ export function SchemaTypeBuilder({
103
173
  }
104
174
  setSaving(true)
105
175
  try {
106
- const type = await createType({ key: effectiveKey, label: label.trim() })
107
-
108
- // Create each declared attribute, in order; the backend attaches them to
109
- // the type by id. Blank rows + enum config are handled by the pure helper.
110
- for (const payload of draftToAttributePayloads(type.id, attributes)) {
111
- await createAttribute(payload)
176
+ if (editing) {
177
+ await persistEdit()
178
+ notify.success?.(`Saved "${label.trim()}".`)
179
+ onCreated?.({ id: existingType!.id, key: effectiveKey, label: label.trim() })
180
+ } else {
181
+ const id = await persistCreate()
182
+ notify.success?.(`Created "${label.trim()}".`)
183
+ onCreated?.({ id, key: effectiveKey, label: label.trim() })
112
184
  }
185
+ } catch (err) {
186
+ notify.error?.(err instanceof Error ? err.message : 'Could not save the type.')
187
+ } finally {
188
+ setSaving(false)
189
+ }
190
+ }
113
191
 
114
- notify.success?.(`Created "${label.trim()}".`)
115
- onCreated?.({ id: type.id, key: effectiveKey, label: label.trim() })
192
+ async function handleDelete() {
193
+ if (!existingType || !deleteType) return
194
+ setSaving(true)
195
+ try {
196
+ await deleteType(existingType.id)
197
+ notify.success?.(`Deleted "${existingType.label}".`)
198
+ onDeleted?.(existingType)
116
199
  } catch (err) {
117
- notify.error?.(err instanceof Error ? err.message : 'Could not create the type.')
200
+ notify.error?.(err instanceof Error ? err.message : 'Could not delete the type.')
118
201
  } finally {
119
202
  setSaving(false)
203
+ setConfirmingDelete(false)
120
204
  }
121
205
  }
122
206
 
@@ -137,16 +221,23 @@ export function SchemaTypeBuilder({
137
221
  />
138
222
  </div>
139
223
  <div className="space-y-1.5">
140
- <Label htmlFor="type-key">Key (optional)</Label>
224
+ <Label htmlFor="type-key">Key{editing ? '' : ' (optional)'}</Label>
141
225
  <Input
142
226
  id="type-key"
143
- value={keyOverride}
227
+ value={editing ? existingType!.key : keyOverride}
144
228
  onChange={(e) => setKeyOverride(e.target.value)}
229
+ disabled={editing}
145
230
  placeholder={toKey(label) || 'auto-generated from the name'}
146
231
  />
147
232
  <p className="text-xs text-gray-500">
148
- Stable identifier used in URLs and the API. Defaults to{' '}
149
- <span className="font-mono">{effectiveKey || '—'}</span>.
233
+ {editing ? (
234
+ <>The key is fixed once a type exists.</>
235
+ ) : (
236
+ <>
237
+ Stable identifier used in URLs and the API. Defaults to{' '}
238
+ <span className="font-mono">{effectiveKey || '—'}</span>.
239
+ </>
240
+ )}
150
241
  </p>
151
242
  </div>
152
243
  </CardContent>
@@ -159,7 +250,7 @@ export function SchemaTypeBuilder({
159
250
  <CardContent className="space-y-4">
160
251
  {attributes.map((attr, i) => (
161
252
  <div
162
- key={i}
253
+ key={attr.id ?? `new-${i}`}
163
254
  className={cn(
164
255
  'grid items-end gap-3 sm:grid-cols-[1fr_180px_auto_auto]',
165
256
  classNames?.fieldRow,
@@ -209,7 +300,7 @@ export function SchemaTypeBuilder({
209
300
  size="icon"
210
301
  onClick={() => removeAttr(i)}
211
302
  aria-label="Remove field"
212
- disabled={attributes.length === 1}
303
+ disabled={attributes.length === 1 && !editing}
213
304
  >
214
305
  <Trash2 className="h-4 w-4" />
215
306
  </Button>
@@ -225,6 +316,46 @@ export function SchemaTypeBuilder({
225
316
  />
226
317
  </div>
227
318
  )}
319
+
320
+ {(attr.dataType === 'number' || attr.dataType === 'integer') && (
321
+ <div className="grid gap-3 sm:col-span-4 sm:grid-cols-2">
322
+ <div className="space-y-1.5">
323
+ <Label htmlFor={`attr-min-${i}`}>Minimum (optional)</Label>
324
+ <Input
325
+ id={`attr-min-${i}`}
326
+ type="number"
327
+ value={attr.min}
328
+ onChange={(e) => updateAttr(i, { min: e.target.value })}
329
+ placeholder="e.g. 0"
330
+ />
331
+ </div>
332
+ <div className="space-y-1.5">
333
+ <Label htmlFor={`attr-max-${i}`}>Maximum (optional)</Label>
334
+ <Input
335
+ id={`attr-max-${i}`}
336
+ type="number"
337
+ value={attr.max}
338
+ onChange={(e) => updateAttr(i, { max: e.target.value })}
339
+ placeholder="e.g. 100"
340
+ />
341
+ </div>
342
+ </div>
343
+ )}
344
+
345
+ {(attr.dataType === 'text' || attr.dataType === 'longtext') && (
346
+ <div className="space-y-1.5 sm:col-span-4">
347
+ <Label htmlFor={`attr-regex-${i}`}>Pattern (regex, optional)</Label>
348
+ <Input
349
+ id={`attr-regex-${i}`}
350
+ value={attr.regex}
351
+ onChange={(e) => updateAttr(i, { regex: e.target.value })}
352
+ placeholder="e.g. [^@]+@[^@]+\.[^@]+"
353
+ />
354
+ <p className="text-xs text-gray-500">
355
+ Values must fully match this pattern.
356
+ </p>
357
+ </div>
358
+ )}
228
359
  </div>
229
360
  ))}
230
361
 
@@ -235,9 +366,53 @@ export function SchemaTypeBuilder({
235
366
  </CardContent>
236
367
  </Card>
237
368
 
238
- <div className="flex justify-end">
369
+ <div className="flex items-center justify-between gap-3">
370
+ <div>
371
+ {editing && deleteType && (
372
+ confirmingDelete ? (
373
+ <div className="flex items-center gap-2">
374
+ <span className="text-sm text-gray-600">Delete this type and all its fields?</span>
375
+ <Button
376
+ type="button"
377
+ variant="destructive"
378
+ size="sm"
379
+ onClick={handleDelete}
380
+ disabled={saving}
381
+ >
382
+ Delete
383
+ </Button>
384
+ <Button
385
+ type="button"
386
+ variant="ghost"
387
+ size="sm"
388
+ onClick={() => setConfirmingDelete(false)}
389
+ disabled={saving}
390
+ >
391
+ Cancel
392
+ </Button>
393
+ </div>
394
+ ) : (
395
+ <Button
396
+ type="button"
397
+ variant="outline"
398
+ onClick={() => setConfirmingDelete(true)}
399
+ disabled={saving}
400
+ >
401
+ <Trash2 className="mr-2 h-4 w-4" />
402
+ Delete type
403
+ </Button>
404
+ )
405
+ )}
406
+ </div>
407
+
239
408
  <Button type="submit" disabled={saving} className={classNames?.submit}>
240
- {saving ? 'Creating…' : submitLabel}
409
+ {editing
410
+ ? saving
411
+ ? 'Saving…'
412
+ : 'Save changes'
413
+ : saving
414
+ ? 'Creating…'
415
+ : submitLabel}
241
416
  </Button>
242
417
  </div>
243
418
  </form>
@@ -57,3 +57,66 @@ describe('SchemaTypeBuilder', () => {
57
57
  expect(notify.error).toHaveBeenCalled()
58
58
  })
59
59
  })
60
+
61
+ describe('SchemaTypeBuilder (edit mode)', () => {
62
+ const existingType = {
63
+ id: 'uuid-1',
64
+ key: 'matter',
65
+ label: 'Matter',
66
+ attributes: [
67
+ { id: 'a1', name: 'title', dataType: 'text' as const, required: true, config: {} },
68
+ { id: 'a2', name: 'stale', dataType: 'text' as const, required: false, config: {} },
69
+ ],
70
+ }
71
+
72
+ function setupEdit() {
73
+ const fns = {
74
+ createType: jest.fn(),
75
+ createAttribute: jest.fn().mockResolvedValue({}),
76
+ updateType: jest.fn().mockResolvedValue({}),
77
+ updateAttribute: jest.fn().mockResolvedValue({}),
78
+ deleteAttribute: jest.fn().mockResolvedValue({}),
79
+ deleteType: jest.fn().mockResolvedValue({}),
80
+ onCreated: jest.fn(),
81
+ onDeleted: jest.fn(),
82
+ }
83
+ const notify = { success: jest.fn(), error: jest.fn() }
84
+ render(<SchemaTypeBuilder {...fns} existingType={existingType} notify={notify} />)
85
+ return { ...fns, notify, user: userEvent.setup() }
86
+ }
87
+
88
+ it('pre-fills the label + a locked key + the existing field names', () => {
89
+ setupEdit()
90
+ expect(screen.getByLabelText('Name')).toHaveValue('Matter')
91
+ expect(screen.getByLabelText('Key')).toBeDisabled()
92
+ expect(screen.getByLabelText('Key')).toHaveValue('matter')
93
+ const fieldNames = screen.getAllByLabelText('Field name').map((el) => (el as HTMLInputElement).value)
94
+ expect(fieldNames).toEqual(['title', 'stale'])
95
+ expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument()
96
+ })
97
+
98
+ it('saves a renamed label + removed field as updateType + deleteAttribute (no create/update of unchanged)', async () => {
99
+ const { updateType, updateAttribute, createAttribute, deleteAttribute, onCreated, user } = setupEdit()
100
+
101
+ await user.clear(screen.getByLabelText('Name'))
102
+ await user.type(screen.getByLabelText('Name'), 'Legal Matter')
103
+ // remove the second field ("stale")
104
+ await user.click(screen.getAllByRole('button', { name: /remove field/i })[1])
105
+ await user.click(screen.getByRole('button', { name: /save changes/i }))
106
+
107
+ await waitFor(() => expect(updateType).toHaveBeenCalledWith('uuid-1', { label: 'Legal Matter' }))
108
+ expect(deleteAttribute).toHaveBeenCalledWith('a2')
109
+ expect(createAttribute).not.toHaveBeenCalled()
110
+ expect(updateAttribute).not.toHaveBeenCalled() // 'title' is unchanged
111
+ expect(onCreated).toHaveBeenCalledWith({ id: 'uuid-1', key: 'matter', label: 'Legal Matter' })
112
+ })
113
+
114
+ it('deletes the type only after the inline confirm', async () => {
115
+ const { deleteType, onDeleted, user } = setupEdit()
116
+ await user.click(screen.getByRole('button', { name: /delete type/i }))
117
+ expect(deleteType).not.toHaveBeenCalled() // confirm step shown, not yet deleted
118
+ await user.click(screen.getByRole('button', { name: /^delete$/i }))
119
+ await waitFor(() => expect(deleteType).toHaveBeenCalledWith('uuid-1'))
120
+ expect(onDeleted).toHaveBeenCalledWith(existingType)
121
+ })
122
+ })
@@ -2,8 +2,12 @@ import {
2
2
  toKey,
3
3
  blankAttribute,
4
4
  draftToAttributePayloads,
5
+ attributeToDraft,
6
+ diffAttributes,
5
7
  DATA_TYPES,
6
8
  type DraftAttribute,
9
+ type ExistingAttribute,
10
+ type EditableDraft,
7
11
  } from '../schema-builder-core'
8
12
 
9
13
  describe('toKey', () => {
@@ -21,22 +25,23 @@ describe('toKey', () => {
21
25
  })
22
26
 
23
27
  describe('blankAttribute', () => {
24
- it('defaults to a non-required text field with no choices', () => {
28
+ it('defaults to a non-required text field with no choices/bounds/regex', () => {
25
29
  expect(blankAttribute()).toEqual({
26
30
  name: '',
27
31
  dataType: 'text',
28
32
  required: false,
29
33
  choices: '',
34
+ min: '',
35
+ max: '',
36
+ regex: '',
30
37
  })
31
38
  })
32
39
  })
33
40
 
34
41
  describe('draftToAttributePayloads', () => {
35
42
  const text = (over: Partial<DraftAttribute> = {}): DraftAttribute => ({
43
+ ...blankAttribute(),
36
44
  name: 'email',
37
- dataType: 'text',
38
- required: false,
39
- choices: '',
40
45
  ...over,
41
46
  })
42
47
 
@@ -74,6 +79,120 @@ describe('draftToAttributePayloads', () => {
74
79
  config: {},
75
80
  })
76
81
  })
82
+
83
+ it('builds numeric config.min/max from the min/max inputs (number/integer)', () => {
84
+ const [payload] = draftToAttributePayloads(5, [
85
+ text({ name: 'qty', dataType: 'integer', min: '1', max: '10' }),
86
+ ])
87
+ expect(payload).toMatchObject({
88
+ name: 'qty',
89
+ dataType: 'integer',
90
+ config: { min: 1, max: 10 },
91
+ })
92
+ })
93
+
94
+ it('sets only the numeric bounds that are provided (blank min/max omitted)', () => {
95
+ const [payload] = draftToAttributePayloads(5, [
96
+ text({ name: 'score', dataType: 'number', min: '0', max: '' }),
97
+ ])
98
+ expect(payload.config).toEqual({ min: 0 })
99
+ })
100
+
101
+ it('builds text config.regex from the regex input (text/longtext)', () => {
102
+ const [payload] = draftToAttributePayloads(5, [
103
+ text({ name: 'email', dataType: 'text', regex: '[^@]+@[^@]+' }),
104
+ ])
105
+ expect(payload.config).toEqual({ regex: '[^@]+@[^@]+' })
106
+ })
107
+
108
+ it('keeps config keys scoped to the dataType (no cross-contamination)', () => {
109
+ // text reads only regex; a stray min/max/choices must not leak into config.
110
+ const [payload] = draftToAttributePayloads(5, [
111
+ text({ name: 'note', dataType: 'text', min: '1', max: '9', choices: 'a,b' }),
112
+ ])
113
+ expect(payload.config).toEqual({})
114
+ })
115
+ })
116
+
117
+ describe('attributeToDraft (hydrate a saved attribute into an editable row)', () => {
118
+ const existing = (over: Partial<ExistingAttribute> = {}): ExistingAttribute => ({
119
+ id: 1,
120
+ name: 'status',
121
+ dataType: 'text',
122
+ required: false,
123
+ config: {},
124
+ ...over,
125
+ })
126
+
127
+ it('round-trips an enum: config.choices -> comma-joined string', () => {
128
+ const d = attributeToDraft(existing({ dataType: 'enum', config: { choices: ['Open', 'Done'] } }))
129
+ expect(d).toMatchObject({ id: 1, dataType: 'enum', choices: 'Open, Done', min: '', max: '', regex: '' })
130
+ })
131
+
132
+ it('round-trips numeric bounds and a regex into their string inputs', () => {
133
+ expect(attributeToDraft(existing({ dataType: 'integer', config: { min: 1, max: 10 } })))
134
+ .toMatchObject({ min: '1', max: '10', choices: '', regex: '' })
135
+ expect(attributeToDraft(existing({ dataType: 'text', config: { regex: '\\d+' } })))
136
+ .toMatchObject({ regex: '\\d+', min: '', max: '' })
137
+ })
138
+
139
+ it('is the inverse of draftToAttributePayloads for the config payload', () => {
140
+ const e = existing({ id: 7, name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } })
141
+ const draft = attributeToDraft(e)
142
+ const [payload] = draftToAttributePayloads(99, [draft])
143
+ expect(payload).toMatchObject({ name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } })
144
+ })
145
+ })
146
+
147
+ describe('diffAttributes (create/update/delete reconciliation)', () => {
148
+ const existing = (over: Partial<ExistingAttribute> = {}): ExistingAttribute => ({
149
+ id: 1, name: 'title', dataType: 'text', required: false, config: {}, ...over,
150
+ })
151
+ const draftOf = (e: ExistingAttribute): EditableDraft => attributeToDraft(e)
152
+ const newRow = (over: Partial<EditableDraft> = {}): EditableDraft => ({
153
+ ...blankAttribute(), name: 'extra', ...over,
154
+ })
155
+
156
+ it('flags a brand-new (id-less) row as a create', () => {
157
+ const orig = [existing()]
158
+ const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0]), newRow({ name: 'note' })])
159
+ expect(creates).toHaveLength(1)
160
+ expect(creates[0]).toMatchObject({ entityType: 3, name: 'note' })
161
+ expect(updates).toEqual([])
162
+ expect(deletes).toEqual([])
163
+ })
164
+
165
+ it('flags a changed existing row as an update (carrying its id)', () => {
166
+ const orig = [existing({ id: 5, name: 'title', dataType: 'text' })]
167
+ const edited = { ...draftOf(orig[0]), name: 'headline', dataType: 'longtext' as const }
168
+ const { creates, updates, deletes } = diffAttributes(3, orig, [edited])
169
+ expect(creates).toEqual([])
170
+ expect(updates).toEqual([{ id: 5, name: 'headline', dataType: 'longtext', required: false, config: {} }])
171
+ expect(deletes).toEqual([])
172
+ })
173
+
174
+ it('does not update an unchanged existing row', () => {
175
+ const orig = [existing({ id: 5, dataType: 'enum', config: { choices: ['a', 'b'] } })]
176
+ const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])])
177
+ expect(creates).toEqual([])
178
+ expect(updates).toEqual([])
179
+ expect(deletes).toEqual([])
180
+ })
181
+
182
+ it('flags an original whose row was removed as a delete', () => {
183
+ const orig = [existing({ id: 5, name: 'title' }), existing({ id: 6, name: 'gone' })]
184
+ const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])])
185
+ expect(creates).toEqual([])
186
+ expect(updates).toEqual([])
187
+ expect(deletes).toEqual([6])
188
+ })
189
+
190
+ it('treats a blanked-name existing row as a delete (not an update)', () => {
191
+ const orig = [existing({ id: 9, name: 'title' })]
192
+ const { updates, deletes } = diffAttributes(3, orig, [{ ...draftOf(orig[0]), name: ' ' }])
193
+ expect(updates).toEqual([])
194
+ expect(deletes).toEqual([9])
195
+ })
77
196
  })
78
197
 
79
198
  describe('DATA_TYPES', () => {
@@ -4,11 +4,24 @@ export type {
4
4
  SchemaTypeBuilderClassNames,
5
5
  SchemaTypeBuilderNotifier,
6
6
  CreatedType,
7
+ ExistingType,
7
8
  } from './SchemaTypeBuilder'
8
9
  export {
9
10
  DATA_TYPES,
10
11
  blankAttribute,
11
12
  toKey,
13
+ draftToPayload,
12
14
  draftToAttributePayloads,
15
+ attributeToDraft,
16
+ diffAttributes,
17
+ } from './schema-builder-core'
18
+ export type {
19
+ DataType,
20
+ DraftAttribute,
21
+ EditableDraft,
22
+ ExistingAttribute,
23
+ AttributePayload,
24
+ AttributeUpdate,
25
+ AttributeEdits,
26
+ SchemaId,
13
27
  } from './schema-builder-core'
14
- export type { DataType, DraftAttribute, AttributePayload } from './schema-builder-core'
@@ -37,10 +37,16 @@ export interface DraftAttribute {
37
37
  required: boolean
38
38
  /** Comma-separated choices, only used when dataType === 'enum'. */
39
39
  choices: string
40
+ /** Lower bound, only used when dataType is number/integer (blank = none). */
41
+ min: string
42
+ /** Upper bound, only used when dataType is number/integer (blank = none). */
43
+ max: string
44
+ /** Validation pattern, only used when dataType is text/longtext (blank = none). */
45
+ regex: string
40
46
  }
41
47
 
42
48
  export function blankAttribute(): DraftAttribute {
43
- return { name: '', dataType: 'text', required: false, choices: '' }
49
+ return { name: '', dataType: 'text', required: false, choices: '', min: '', max: '', regex: '' }
44
50
  }
45
51
 
46
52
  /** Slugify a label into a stable, lowercase key the backend stores. */
@@ -52,41 +58,151 @@ export function toKey(label: string): string {
52
58
  .replace(/^_+|_+$/g, '')
53
59
  }
54
60
 
61
+ /** Server-assigned ids are UUID strings; numbers are tolerated for tests/legacy. */
62
+ export type SchemaId = string | number
63
+
55
64
  export interface AttributePayload {
56
- entityType: number
65
+ entityType: SchemaId
57
66
  name: string
58
67
  dataType: DataType
59
68
  required: boolean
60
69
  config: Record<string, unknown>
61
70
  }
62
71
 
72
+ /** Build the dataType-scoped config for one draft row. Keys match what the
73
+ * backend enforces (enum: choices, number/integer: min/max, text/longtext:
74
+ * regex). Numeric bounds + regex are omitted when blank; an enum always carries
75
+ * its choices. */
76
+ function buildConfig(attr: DraftAttribute): Record<string, unknown> {
77
+ const config: Record<string, unknown> = {}
78
+ if (attr.dataType === 'enum') {
79
+ config.choices = attr.choices
80
+ .split(',')
81
+ .map((c) => c.trim())
82
+ .filter(Boolean)
83
+ } else if (attr.dataType === 'number' || attr.dataType === 'integer') {
84
+ const min = Number(attr.min)
85
+ const max = Number(attr.max)
86
+ if (attr.min.trim() !== '' && !Number.isNaN(min)) config.min = min
87
+ if (attr.max.trim() !== '' && !Number.isNaN(max)) config.max = max
88
+ } else if (attr.dataType === 'text' || attr.dataType === 'longtext') {
89
+ if (attr.regex.trim() !== '') config.regex = attr.regex.trim()
90
+ }
91
+ return config
92
+ }
93
+
94
+ /** One draft row → a createAttribute() payload, or null for a blank-name row. */
95
+ export function draftToPayload(
96
+ entityTypeId: SchemaId,
97
+ attr: DraftAttribute,
98
+ ): AttributePayload | null {
99
+ const name = attr.name.trim()
100
+ if (!name) return null
101
+ return { entityType: entityTypeId, name, dataType: attr.dataType, required: attr.required, config: buildConfig(attr) }
102
+ }
103
+
63
104
  /**
64
105
  * Turn the draft field rows into createAttribute() inputs for a just-created
65
- * type: drop blank-name rows, trim names, and build enum `config.choices` from
66
- * the comma-separated input (trimmed, empties removed).
106
+ * type: drop blank-name rows, trim names, and build the dataType-scoped config.
67
107
  */
68
108
  export function draftToAttributePayloads(
69
- entityTypeId: number,
109
+ entityTypeId: SchemaId,
70
110
  drafts: DraftAttribute[],
71
111
  ): AttributePayload[] {
72
112
  const out: AttributePayload[] = []
73
113
  for (const attr of drafts) {
74
- const name = attr.name.trim()
75
- if (!name) continue
76
- const config: Record<string, unknown> = {}
77
- if (attr.dataType === 'enum') {
78
- config.choices = attr.choices
79
- .split(',')
80
- .map((c) => c.trim())
81
- .filter(Boolean)
82
- }
83
- out.push({
84
- entityType: entityTypeId,
85
- name,
86
- dataType: attr.dataType,
87
- required: attr.required,
88
- config,
89
- })
114
+ const payload = draftToPayload(entityTypeId, attr)
115
+ if (payload) out.push(payload)
90
116
  }
91
117
  return out
92
118
  }
119
+
120
+ // ---- edit mode (S4): hydrate existing attributes + diff against drafts ----
121
+
122
+ /** A persisted attribute as the schema API returns it (camelCase via the client). */
123
+ export interface ExistingAttribute {
124
+ id: SchemaId
125
+ name: string
126
+ dataType: DataType
127
+ required: boolean
128
+ config: Record<string, unknown>
129
+ }
130
+
131
+ /** A draft row that may correspond to a persisted attribute (carries its id). */
132
+ export interface EditableDraft extends DraftAttribute {
133
+ id?: SchemaId
134
+ }
135
+
136
+ /** Inverse of draftToPayload: hydrate an editable row from a saved attribute,
137
+ * back-filling the dataType-scoped config into its string inputs. */
138
+ export function attributeToDraft(attr: ExistingAttribute): EditableDraft {
139
+ const cfg = attr.config || {}
140
+ const choices = Array.isArray(cfg.choices) ? (cfg.choices as unknown[]).join(', ') : ''
141
+ const min = cfg.min !== undefined && cfg.min !== null ? String(cfg.min) : ''
142
+ const max = cfg.max !== undefined && cfg.max !== null ? String(cfg.max) : ''
143
+ const regex = typeof cfg.regex === 'string' ? cfg.regex : ''
144
+ return {
145
+ id: attr.id,
146
+ name: attr.name,
147
+ dataType: attr.dataType,
148
+ required: attr.required,
149
+ choices,
150
+ min,
151
+ max,
152
+ regex,
153
+ }
154
+ }
155
+
156
+ /** A single attribute update (the id + the writable payload fields). */
157
+ export type AttributeUpdate = { id: SchemaId } & Omit<AttributePayload, 'entityType'>
158
+
159
+ export interface AttributeEdits {
160
+ creates: AttributePayload[]
161
+ updates: AttributeUpdate[]
162
+ deletes: SchemaId[]
163
+ }
164
+
165
+ function sameConfig(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
166
+ const ak = Object.keys(a).sort()
167
+ const bk = Object.keys(b).sort()
168
+ if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false
169
+ return ak.every((k) => JSON.stringify(a[k]) === JSON.stringify(b[k]))
170
+ }
171
+
172
+ /**
173
+ * Reconcile the edited draft rows against the type's persisted attributes into
174
+ * the API calls needed: id-less rows -> creates, changed existing rows ->
175
+ * updates, and originals whose row was removed (or blanked) -> deletes.
176
+ */
177
+ export function diffAttributes(
178
+ entityTypeId: SchemaId,
179
+ originals: ExistingAttribute[],
180
+ drafts: EditableDraft[],
181
+ ): AttributeEdits {
182
+ const creates: AttributePayload[] = []
183
+ const updates: AttributeUpdate[] = []
184
+ const keptIds = new Set<SchemaId>()
185
+
186
+ for (const draft of drafts) {
187
+ const payload = draftToPayload(entityTypeId, draft)
188
+ if (!payload) continue // blank-name row: a new blank is ignored; a blanked existing falls through to delete
189
+ if (draft.id == null) {
190
+ creates.push(payload)
191
+ continue
192
+ }
193
+ keptIds.add(draft.id)
194
+ const orig = originals.find((o) => o.id === draft.id)
195
+ const changed =
196
+ !orig ||
197
+ orig.name !== payload.name ||
198
+ orig.dataType !== payload.dataType ||
199
+ orig.required !== payload.required ||
200
+ !sameConfig(orig.config || {}, payload.config)
201
+ if (changed) {
202
+ updates.push({ id: draft.id, name: payload.name, dataType: payload.dataType, required: payload.required, config: payload.config })
203
+ }
204
+ }
205
+
206
+ const deletes = originals.filter((o) => !keptIds.has(o.id)).map((o) => o.id)
207
+ return { creates, updates, deletes }
208
+ }