@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 +1 -1
- package/src/components/schema/SchemaTypeBuilder.tsx +206 -31
- package/src/components/schema/__tests__/SchemaTypeBuilder.test.tsx +63 -0
- package/src/components/schema/__tests__/schema-builder-core.test.ts +123 -4
- package/src/components/schema/index.ts +14 -1
- package/src/components/schema/schema-builder-core.ts +137 -21
package/package.json
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
53
|
-
/** Persist one attribute against
|
|
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
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
121
|
+
const editing = !!existingType
|
|
122
|
+
const [label, setLabel] = useState(existingType?.label ?? '')
|
|
80
123
|
const [keyOverride, setKeyOverride] = useState('')
|
|
81
|
-
const [attributes, setAttributes] = useState<
|
|
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<
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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-
|
|
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
|
-
{
|
|
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:
|
|
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
|
|
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:
|
|
109
|
+
entityTypeId: SchemaId,
|
|
70
110
|
drafts: DraftAttribute[],
|
|
71
111
|
): AttributePayload[] {
|
|
72
112
|
const out: AttributePayload[] = []
|
|
73
113
|
for (const attr of drafts) {
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
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
|
+
}
|