@startsimpli/ui 0.4.35 → 0.4.37
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 +3 -3
- package/src/components/schema/RelationshipBuilder.tsx +167 -0
- package/src/components/schema/SchemaTypeBuilder.tsx +166 -31
- package/src/components/schema/__tests__/RelationshipBuilder.test.tsx +34 -0
- package/src/components/schema/__tests__/SchemaTypeBuilder.test.tsx +63 -0
- package/src/components/schema/__tests__/schema-builder-core.test.ts +98 -0
- package/src/components/schema/index.ts +21 -1
- package/src/components/schema/schema-builder-core.ts +137 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.37",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
34
34
|
"react": "^18.0.0 || ^19.0.0",
|
|
35
35
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
36
|
-
"@startsimpli/
|
|
37
|
-
"@startsimpli/
|
|
36
|
+
"@startsimpli/auth": "0.4.28",
|
|
37
|
+
"@startsimpli/api": "0.5.35"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@startsimpli/api": {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import { Input } from '../ui/input'
|
|
6
|
+
import { Label } from '../ui/label'
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
} from '../ui/select'
|
|
15
|
+
import { notify as defaultNotify } from '../toast'
|
|
16
|
+
import { cn } from '../../lib/utils'
|
|
17
|
+
import { suggestRelationshipKey, type SchemaId } from './schema-builder-core'
|
|
18
|
+
import type { SchemaTypeBuilderNotifier } from './SchemaTypeBuilder'
|
|
19
|
+
|
|
20
|
+
/** A type option the edge can connect (id + its key/label for display). */
|
|
21
|
+
export interface RelationshipTypeOption {
|
|
22
|
+
id: SchemaId
|
|
23
|
+
key: string
|
|
24
|
+
label: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RelationshipBuilderClassNames {
|
|
28
|
+
root?: string
|
|
29
|
+
card?: string
|
|
30
|
+
submit?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RelationshipBuilderProps {
|
|
34
|
+
/** The org's entity types — the selectable endpoints of the edge. */
|
|
35
|
+
types: RelationshipTypeOption[]
|
|
36
|
+
/** Persist a relationship definition (typed edge source_type -> target_type). */
|
|
37
|
+
createRelationship: (input: {
|
|
38
|
+
key: string
|
|
39
|
+
sourceType: SchemaId
|
|
40
|
+
targetType: SchemaId
|
|
41
|
+
}) => Promise<unknown>
|
|
42
|
+
/** Fired after the relationship def is created (refetch here). */
|
|
43
|
+
onCreated?: (created: { key: string }) => void
|
|
44
|
+
notify?: SchemaTypeBuilderNotifier
|
|
45
|
+
classNames?: RelationshipBuilderClassNames
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* No-code relationship builder (768w.10.4 S5): connect two entity types with a
|
|
50
|
+
* typed edge. The persistence layer is injected via `createRelationship`; the
|
|
51
|
+
* consuming app supplies the type list + handles refetch in `onCreated`.
|
|
52
|
+
*/
|
|
53
|
+
export function RelationshipBuilder({
|
|
54
|
+
types,
|
|
55
|
+
createRelationship,
|
|
56
|
+
onCreated,
|
|
57
|
+
notify = defaultNotify,
|
|
58
|
+
classNames,
|
|
59
|
+
}: RelationshipBuilderProps) {
|
|
60
|
+
const [sourceId, setSourceId] = useState('')
|
|
61
|
+
const [targetId, setTargetId] = useState('')
|
|
62
|
+
const [keyOverride, setKeyOverride] = useState('')
|
|
63
|
+
const [saving, setSaving] = useState(false)
|
|
64
|
+
|
|
65
|
+
const sourceKey = types.find((t) => String(t.id) === sourceId)?.key ?? ''
|
|
66
|
+
const targetKey = types.find((t) => String(t.id) === targetId)?.key ?? ''
|
|
67
|
+
const effectiveKey = keyOverride.trim() || suggestRelationshipKey(sourceKey, targetKey)
|
|
68
|
+
|
|
69
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
if (!sourceId || !targetId) {
|
|
72
|
+
notify.error?.('Pick a source and a target type.')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (!effectiveKey) {
|
|
76
|
+
notify.error?.('Give the relationship a name.')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
setSaving(true)
|
|
80
|
+
try {
|
|
81
|
+
await createRelationship({ key: effectiveKey, sourceType: sourceId, targetType: targetId })
|
|
82
|
+
notify.success?.(`Connected ${sourceKey} → ${targetKey}.`)
|
|
83
|
+
onCreated?.({ key: effectiveKey })
|
|
84
|
+
setKeyOverride('')
|
|
85
|
+
} catch (err) {
|
|
86
|
+
notify.error?.(err instanceof Error ? err.message : 'Could not create the relationship.')
|
|
87
|
+
} finally {
|
|
88
|
+
setSaving(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={handleSubmit} className={cn('space-y-6', classNames?.root)}>
|
|
94
|
+
<Card className={classNames?.card}>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>Connect two types</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent className="space-y-4">
|
|
99
|
+
{types.length === 0 ? (
|
|
100
|
+
<p className="text-sm text-gray-500">
|
|
101
|
+
Create at least one entity type first, then connect them.
|
|
102
|
+
</p>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
106
|
+
<div className="space-y-1.5">
|
|
107
|
+
<Label>From (source)</Label>
|
|
108
|
+
<Select value={sourceId} onValueChange={setSourceId}>
|
|
109
|
+
<SelectTrigger>
|
|
110
|
+
<SelectValue placeholder="Pick a type" />
|
|
111
|
+
</SelectTrigger>
|
|
112
|
+
<SelectContent>
|
|
113
|
+
{types.map((t) => (
|
|
114
|
+
<SelectItem key={String(t.id)} value={String(t.id)}>
|
|
115
|
+
{t.label}
|
|
116
|
+
</SelectItem>
|
|
117
|
+
))}
|
|
118
|
+
</SelectContent>
|
|
119
|
+
</Select>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-1.5">
|
|
122
|
+
<Label>To (target)</Label>
|
|
123
|
+
<Select value={targetId} onValueChange={setTargetId}>
|
|
124
|
+
<SelectTrigger>
|
|
125
|
+
<SelectValue placeholder="Pick a type" />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{types.map((t) => (
|
|
129
|
+
<SelectItem key={String(t.id)} value={String(t.id)}>
|
|
130
|
+
{t.label}
|
|
131
|
+
</SelectItem>
|
|
132
|
+
))}
|
|
133
|
+
</SelectContent>
|
|
134
|
+
</Select>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
<Label htmlFor="rel-key">Name (optional)</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="rel-key"
|
|
142
|
+
value={keyOverride}
|
|
143
|
+
onChange={(e) => setKeyOverride(e.target.value)}
|
|
144
|
+
placeholder={effectiveKey || 'auto-generated from the two types'}
|
|
145
|
+
/>
|
|
146
|
+
<p className="text-xs text-gray-500">
|
|
147
|
+
Stable identifier for this edge. Defaults to{' '}
|
|
148
|
+
<span className="font-mono">{effectiveKey || '—'}</span>.
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
|
|
156
|
+
<div className="flex justify-end">
|
|
157
|
+
<Button
|
|
158
|
+
type="submit"
|
|
159
|
+
disabled={saving || types.length === 0}
|
|
160
|
+
className={classNames?.submit}
|
|
161
|
+
>
|
|
162
|
+
{saving ? 'Connecting…' : 'Define relationship'}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -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>
|
|
@@ -275,9 +366,53 @@ export function SchemaTypeBuilder({
|
|
|
275
366
|
</CardContent>
|
|
276
367
|
</Card>
|
|
277
368
|
|
|
278
|
-
<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
|
+
|
|
279
408
|
<Button type="submit" disabled={saving} className={classNames?.submit}>
|
|
280
|
-
{
|
|
409
|
+
{editing
|
|
410
|
+
? saving
|
|
411
|
+
? 'Saving…'
|
|
412
|
+
: 'Save changes'
|
|
413
|
+
: saving
|
|
414
|
+
? 'Creating…'
|
|
415
|
+
: submitLabel}
|
|
281
416
|
</Button>
|
|
282
417
|
</div>
|
|
283
418
|
</form>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { RelationshipBuilder } from '../RelationshipBuilder'
|
|
4
|
+
|
|
5
|
+
const types = [
|
|
6
|
+
{ id: 'uuid-artist', key: 'artist', label: 'Artist' },
|
|
7
|
+
{ id: 'uuid-release', key: 'release', label: 'Release' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
describe('RelationshipBuilder', () => {
|
|
11
|
+
it('renders source + target pickers and a define button', () => {
|
|
12
|
+
render(<RelationshipBuilder types={types} createRelationship={jest.fn()} />)
|
|
13
|
+
expect(screen.getByText('From (source)')).toBeInTheDocument()
|
|
14
|
+
expect(screen.getByText('To (target)')).toBeInTheDocument()
|
|
15
|
+
expect(screen.getByRole('button', { name: /define relationship/i })).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('warns and does not persist when no endpoints are chosen', async () => {
|
|
19
|
+
const createRelationship = jest.fn().mockResolvedValue({})
|
|
20
|
+
const notify = { success: jest.fn(), error: jest.fn() }
|
|
21
|
+
render(
|
|
22
|
+
<RelationshipBuilder types={types} createRelationship={createRelationship} notify={notify} />,
|
|
23
|
+
)
|
|
24
|
+
await userEvent.setup().click(screen.getByRole('button', { name: /define relationship/i }))
|
|
25
|
+
expect(createRelationship).not.toHaveBeenCalled()
|
|
26
|
+
expect(notify.error).toHaveBeenCalled()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('prompts to create types first when none exist (submit disabled)', () => {
|
|
30
|
+
render(<RelationshipBuilder types={[]} createRelationship={jest.fn()} />)
|
|
31
|
+
expect(screen.getByText(/create at least one entity type first/i)).toBeInTheDocument()
|
|
32
|
+
expect(screen.getByRole('button', { name: /define relationship/i })).toBeDisabled()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -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,13 @@ import {
|
|
|
2
2
|
toKey,
|
|
3
3
|
blankAttribute,
|
|
4
4
|
draftToAttributePayloads,
|
|
5
|
+
attributeToDraft,
|
|
6
|
+
diffAttributes,
|
|
7
|
+
suggestRelationshipKey,
|
|
5
8
|
DATA_TYPES,
|
|
6
9
|
type DraftAttribute,
|
|
10
|
+
type ExistingAttribute,
|
|
11
|
+
type EditableDraft,
|
|
7
12
|
} from '../schema-builder-core'
|
|
8
13
|
|
|
9
14
|
describe('toKey', () => {
|
|
@@ -110,6 +115,99 @@ describe('draftToAttributePayloads', () => {
|
|
|
110
115
|
})
|
|
111
116
|
})
|
|
112
117
|
|
|
118
|
+
describe('attributeToDraft (hydrate a saved attribute into an editable row)', () => {
|
|
119
|
+
const existing = (over: Partial<ExistingAttribute> = {}): ExistingAttribute => ({
|
|
120
|
+
id: 1,
|
|
121
|
+
name: 'status',
|
|
122
|
+
dataType: 'text',
|
|
123
|
+
required: false,
|
|
124
|
+
config: {},
|
|
125
|
+
...over,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('round-trips an enum: config.choices -> comma-joined string', () => {
|
|
129
|
+
const d = attributeToDraft(existing({ dataType: 'enum', config: { choices: ['Open', 'Done'] } }))
|
|
130
|
+
expect(d).toMatchObject({ id: 1, dataType: 'enum', choices: 'Open, Done', min: '', max: '', regex: '' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('round-trips numeric bounds and a regex into their string inputs', () => {
|
|
134
|
+
expect(attributeToDraft(existing({ dataType: 'integer', config: { min: 1, max: 10 } })))
|
|
135
|
+
.toMatchObject({ min: '1', max: '10', choices: '', regex: '' })
|
|
136
|
+
expect(attributeToDraft(existing({ dataType: 'text', config: { regex: '\\d+' } })))
|
|
137
|
+
.toMatchObject({ regex: '\\d+', min: '', max: '' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is the inverse of draftToAttributePayloads for the config payload', () => {
|
|
141
|
+
const e = existing({ id: 7, name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } })
|
|
142
|
+
const draft = attributeToDraft(e)
|
|
143
|
+
const [payload] = draftToAttributePayloads(99, [draft])
|
|
144
|
+
expect(payload).toMatchObject({ name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } })
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('diffAttributes (create/update/delete reconciliation)', () => {
|
|
149
|
+
const existing = (over: Partial<ExistingAttribute> = {}): ExistingAttribute => ({
|
|
150
|
+
id: 1, name: 'title', dataType: 'text', required: false, config: {}, ...over,
|
|
151
|
+
})
|
|
152
|
+
const draftOf = (e: ExistingAttribute): EditableDraft => attributeToDraft(e)
|
|
153
|
+
const newRow = (over: Partial<EditableDraft> = {}): EditableDraft => ({
|
|
154
|
+
...blankAttribute(), name: 'extra', ...over,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('flags a brand-new (id-less) row as a create', () => {
|
|
158
|
+
const orig = [existing()]
|
|
159
|
+
const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0]), newRow({ name: 'note' })])
|
|
160
|
+
expect(creates).toHaveLength(1)
|
|
161
|
+
expect(creates[0]).toMatchObject({ entityType: 3, name: 'note' })
|
|
162
|
+
expect(updates).toEqual([])
|
|
163
|
+
expect(deletes).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('flags a changed existing row as an update (carrying its id)', () => {
|
|
167
|
+
const orig = [existing({ id: 5, name: 'title', dataType: 'text' })]
|
|
168
|
+
const edited = { ...draftOf(orig[0]), name: 'headline', dataType: 'longtext' as const }
|
|
169
|
+
const { creates, updates, deletes } = diffAttributes(3, orig, [edited])
|
|
170
|
+
expect(creates).toEqual([])
|
|
171
|
+
expect(updates).toEqual([{ id: 5, name: 'headline', dataType: 'longtext', required: false, config: {} }])
|
|
172
|
+
expect(deletes).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('does not update an unchanged existing row', () => {
|
|
176
|
+
const orig = [existing({ id: 5, dataType: 'enum', config: { choices: ['a', 'b'] } })]
|
|
177
|
+
const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])])
|
|
178
|
+
expect(creates).toEqual([])
|
|
179
|
+
expect(updates).toEqual([])
|
|
180
|
+
expect(deletes).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('flags an original whose row was removed as a delete', () => {
|
|
184
|
+
const orig = [existing({ id: 5, name: 'title' }), existing({ id: 6, name: 'gone' })]
|
|
185
|
+
const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])])
|
|
186
|
+
expect(creates).toEqual([])
|
|
187
|
+
expect(updates).toEqual([])
|
|
188
|
+
expect(deletes).toEqual([6])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('treats a blanked-name existing row as a delete (not an update)', () => {
|
|
192
|
+
const orig = [existing({ id: 9, name: 'title' })]
|
|
193
|
+
const { updates, deletes } = diffAttributes(3, orig, [{ ...draftOf(orig[0]), name: ' ' }])
|
|
194
|
+
expect(updates).toEqual([])
|
|
195
|
+
expect(deletes).toEqual([9])
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('suggestRelationshipKey', () => {
|
|
200
|
+
it('joins source + target into a slugified edge key', () => {
|
|
201
|
+
expect(suggestRelationshipKey('artist', 'release')).toBe('artist_release')
|
|
202
|
+
expect(suggestRelationshipKey('Purchase Order', 'Line Item')).toBe('purchase_order_line_item')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('is empty until both ends are chosen', () => {
|
|
206
|
+
expect(suggestRelationshipKey('', 'release')).toBe('')
|
|
207
|
+
expect(suggestRelationshipKey('artist', '')).toBe('')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
113
211
|
describe('DATA_TYPES', () => {
|
|
114
212
|
it('exposes the eight backend data types as {value,label} options', () => {
|
|
115
213
|
expect(DATA_TYPES.map((d) => d.value)).toEqual([
|
|
@@ -4,11 +4,31 @@ export type {
|
|
|
4
4
|
SchemaTypeBuilderClassNames,
|
|
5
5
|
SchemaTypeBuilderNotifier,
|
|
6
6
|
CreatedType,
|
|
7
|
+
ExistingType,
|
|
7
8
|
} from './SchemaTypeBuilder'
|
|
9
|
+
export { RelationshipBuilder } from './RelationshipBuilder'
|
|
10
|
+
export type {
|
|
11
|
+
RelationshipBuilderProps,
|
|
12
|
+
RelationshipBuilderClassNames,
|
|
13
|
+
RelationshipTypeOption,
|
|
14
|
+
} from './RelationshipBuilder'
|
|
8
15
|
export {
|
|
9
16
|
DATA_TYPES,
|
|
10
17
|
blankAttribute,
|
|
11
18
|
toKey,
|
|
19
|
+
suggestRelationshipKey,
|
|
20
|
+
draftToPayload,
|
|
12
21
|
draftToAttributePayloads,
|
|
22
|
+
attributeToDraft,
|
|
23
|
+
diffAttributes,
|
|
24
|
+
} from './schema-builder-core'
|
|
25
|
+
export type {
|
|
26
|
+
DataType,
|
|
27
|
+
DraftAttribute,
|
|
28
|
+
EditableDraft,
|
|
29
|
+
ExistingAttribute,
|
|
30
|
+
AttributePayload,
|
|
31
|
+
AttributeUpdate,
|
|
32
|
+
AttributeEdits,
|
|
33
|
+
SchemaId,
|
|
13
34
|
} from './schema-builder-core'
|
|
14
|
-
export type { DataType, DraftAttribute, AttributePayload } from './schema-builder-core'
|
|
@@ -58,51 +58,158 @@ export function toKey(label: string): string {
|
|
|
58
58
|
.replace(/^_+|_+$/g, '')
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Suggest a relationship (edge) key from its endpoint type names. Empty until
|
|
62
|
+
* both ends are chosen. */
|
|
63
|
+
export function suggestRelationshipKey(source: string, target: string): string {
|
|
64
|
+
if (!source.trim() || !target.trim()) return ''
|
|
65
|
+
return toKey(`${source}_${target}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Server-assigned ids are UUID strings; numbers are tolerated for tests/legacy. */
|
|
69
|
+
export type SchemaId = string | number
|
|
70
|
+
|
|
61
71
|
export interface AttributePayload {
|
|
62
|
-
entityType:
|
|
72
|
+
entityType: SchemaId
|
|
63
73
|
name: string
|
|
64
74
|
dataType: DataType
|
|
65
75
|
required: boolean
|
|
66
76
|
config: Record<string, unknown>
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
/** Build the dataType-scoped config for one draft row. Keys match what the
|
|
80
|
+
* backend enforces (enum: choices, number/integer: min/max, text/longtext:
|
|
81
|
+
* regex). Numeric bounds + regex are omitted when blank; an enum always carries
|
|
82
|
+
* its choices. */
|
|
83
|
+
function buildConfig(attr: DraftAttribute): Record<string, unknown> {
|
|
84
|
+
const config: Record<string, unknown> = {}
|
|
85
|
+
if (attr.dataType === 'enum') {
|
|
86
|
+
config.choices = attr.choices
|
|
87
|
+
.split(',')
|
|
88
|
+
.map((c) => c.trim())
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
} else if (attr.dataType === 'number' || attr.dataType === 'integer') {
|
|
91
|
+
const min = Number(attr.min)
|
|
92
|
+
const max = Number(attr.max)
|
|
93
|
+
if (attr.min.trim() !== '' && !Number.isNaN(min)) config.min = min
|
|
94
|
+
if (attr.max.trim() !== '' && !Number.isNaN(max)) config.max = max
|
|
95
|
+
} else if (attr.dataType === 'text' || attr.dataType === 'longtext') {
|
|
96
|
+
if (attr.regex.trim() !== '') config.regex = attr.regex.trim()
|
|
97
|
+
}
|
|
98
|
+
return config
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One draft row → a createAttribute() payload, or null for a blank-name row. */
|
|
102
|
+
export function draftToPayload(
|
|
103
|
+
entityTypeId: SchemaId,
|
|
104
|
+
attr: DraftAttribute,
|
|
105
|
+
): AttributePayload | null {
|
|
106
|
+
const name = attr.name.trim()
|
|
107
|
+
if (!name) return null
|
|
108
|
+
return { entityType: entityTypeId, name, dataType: attr.dataType, required: attr.required, config: buildConfig(attr) }
|
|
109
|
+
}
|
|
110
|
+
|
|
69
111
|
/**
|
|
70
112
|
* Turn the draft field rows into createAttribute() inputs for a just-created
|
|
71
|
-
* type: drop blank-name rows, trim names, and build
|
|
72
|
-
* the comma-separated input (trimmed, empties removed).
|
|
113
|
+
* type: drop blank-name rows, trim names, and build the dataType-scoped config.
|
|
73
114
|
*/
|
|
74
115
|
export function draftToAttributePayloads(
|
|
75
|
-
entityTypeId:
|
|
116
|
+
entityTypeId: SchemaId,
|
|
76
117
|
drafts: DraftAttribute[],
|
|
77
118
|
): AttributePayload[] {
|
|
78
119
|
const out: AttributePayload[] = []
|
|
79
120
|
for (const attr of drafts) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
// Config keys are scoped to the dataType + match what the backend enforces
|
|
83
|
-
// (enum: choices, number/integer: min/max, text/longtext: regex). Numeric
|
|
84
|
-
// bounds + regex are omitted when blank; an enum always carries its choices.
|
|
85
|
-
const config: Record<string, unknown> = {}
|
|
86
|
-
if (attr.dataType === 'enum') {
|
|
87
|
-
config.choices = attr.choices
|
|
88
|
-
.split(',')
|
|
89
|
-
.map((c) => c.trim())
|
|
90
|
-
.filter(Boolean)
|
|
91
|
-
} else if (attr.dataType === 'number' || attr.dataType === 'integer') {
|
|
92
|
-
const min = Number(attr.min)
|
|
93
|
-
const max = Number(attr.max)
|
|
94
|
-
if (attr.min.trim() !== '' && !Number.isNaN(min)) config.min = min
|
|
95
|
-
if (attr.max.trim() !== '' && !Number.isNaN(max)) config.max = max
|
|
96
|
-
} else if (attr.dataType === 'text' || attr.dataType === 'longtext') {
|
|
97
|
-
if (attr.regex.trim() !== '') config.regex = attr.regex.trim()
|
|
98
|
-
}
|
|
99
|
-
out.push({
|
|
100
|
-
entityType: entityTypeId,
|
|
101
|
-
name,
|
|
102
|
-
dataType: attr.dataType,
|
|
103
|
-
required: attr.required,
|
|
104
|
-
config,
|
|
105
|
-
})
|
|
121
|
+
const payload = draftToPayload(entityTypeId, attr)
|
|
122
|
+
if (payload) out.push(payload)
|
|
106
123
|
}
|
|
107
124
|
return out
|
|
108
125
|
}
|
|
126
|
+
|
|
127
|
+
// ---- edit mode (S4): hydrate existing attributes + diff against drafts ----
|
|
128
|
+
|
|
129
|
+
/** A persisted attribute as the schema API returns it (camelCase via the client). */
|
|
130
|
+
export interface ExistingAttribute {
|
|
131
|
+
id: SchemaId
|
|
132
|
+
name: string
|
|
133
|
+
dataType: DataType
|
|
134
|
+
required: boolean
|
|
135
|
+
config: Record<string, unknown>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A draft row that may correspond to a persisted attribute (carries its id). */
|
|
139
|
+
export interface EditableDraft extends DraftAttribute {
|
|
140
|
+
id?: SchemaId
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Inverse of draftToPayload: hydrate an editable row from a saved attribute,
|
|
144
|
+
* back-filling the dataType-scoped config into its string inputs. */
|
|
145
|
+
export function attributeToDraft(attr: ExistingAttribute): EditableDraft {
|
|
146
|
+
const cfg = attr.config || {}
|
|
147
|
+
const choices = Array.isArray(cfg.choices) ? (cfg.choices as unknown[]).join(', ') : ''
|
|
148
|
+
const min = cfg.min !== undefined && cfg.min !== null ? String(cfg.min) : ''
|
|
149
|
+
const max = cfg.max !== undefined && cfg.max !== null ? String(cfg.max) : ''
|
|
150
|
+
const regex = typeof cfg.regex === 'string' ? cfg.regex : ''
|
|
151
|
+
return {
|
|
152
|
+
id: attr.id,
|
|
153
|
+
name: attr.name,
|
|
154
|
+
dataType: attr.dataType,
|
|
155
|
+
required: attr.required,
|
|
156
|
+
choices,
|
|
157
|
+
min,
|
|
158
|
+
max,
|
|
159
|
+
regex,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A single attribute update (the id + the writable payload fields). */
|
|
164
|
+
export type AttributeUpdate = { id: SchemaId } & Omit<AttributePayload, 'entityType'>
|
|
165
|
+
|
|
166
|
+
export interface AttributeEdits {
|
|
167
|
+
creates: AttributePayload[]
|
|
168
|
+
updates: AttributeUpdate[]
|
|
169
|
+
deletes: SchemaId[]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sameConfig(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
|
173
|
+
const ak = Object.keys(a).sort()
|
|
174
|
+
const bk = Object.keys(b).sort()
|
|
175
|
+
if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false
|
|
176
|
+
return ak.every((k) => JSON.stringify(a[k]) === JSON.stringify(b[k]))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reconcile the edited draft rows against the type's persisted attributes into
|
|
181
|
+
* the API calls needed: id-less rows -> creates, changed existing rows ->
|
|
182
|
+
* updates, and originals whose row was removed (or blanked) -> deletes.
|
|
183
|
+
*/
|
|
184
|
+
export function diffAttributes(
|
|
185
|
+
entityTypeId: SchemaId,
|
|
186
|
+
originals: ExistingAttribute[],
|
|
187
|
+
drafts: EditableDraft[],
|
|
188
|
+
): AttributeEdits {
|
|
189
|
+
const creates: AttributePayload[] = []
|
|
190
|
+
const updates: AttributeUpdate[] = []
|
|
191
|
+
const keptIds = new Set<SchemaId>()
|
|
192
|
+
|
|
193
|
+
for (const draft of drafts) {
|
|
194
|
+
const payload = draftToPayload(entityTypeId, draft)
|
|
195
|
+
if (!payload) continue // blank-name row: a new blank is ignored; a blanked existing falls through to delete
|
|
196
|
+
if (draft.id == null) {
|
|
197
|
+
creates.push(payload)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
keptIds.add(draft.id)
|
|
201
|
+
const orig = originals.find((o) => o.id === draft.id)
|
|
202
|
+
const changed =
|
|
203
|
+
!orig ||
|
|
204
|
+
orig.name !== payload.name ||
|
|
205
|
+
orig.dataType !== payload.dataType ||
|
|
206
|
+
orig.required !== payload.required ||
|
|
207
|
+
!sameConfig(orig.config || {}, payload.config)
|
|
208
|
+
if (changed) {
|
|
209
|
+
updates.push({ id: draft.id, name: payload.name, dataType: payload.dataType, required: payload.required, config: payload.config })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const deletes = originals.filter((o) => !keptIds.has(o.id)).map((o) => o.id)
|
|
214
|
+
return { creates, updates, deletes }
|
|
215
|
+
}
|