@startsimpli/ui 0.4.33 → 0.4.35
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/index.ts +3 -0
- package/src/components/schema/SchemaTypeBuilder.tsx +285 -0
- package/src/components/schema/__tests__/SchemaTypeBuilder.test.tsx +59 -0
- package/src/components/schema/__tests__/schema-builder-core.test.ts +126 -0
- package/src/components/schema/index.ts +14 -0
- package/src/components/schema/schema-builder-core.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.35",
|
|
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/api": "0.5.35",
|
|
37
|
+
"@startsimpli/auth": "0.4.28"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@startsimpli/api": {
|
package/src/components/index.ts
CHANGED
|
@@ -92,6 +92,9 @@ export * from './wizard'
|
|
|
92
92
|
// Account (profile + password forms)
|
|
93
93
|
export * from './account'
|
|
94
94
|
|
|
95
|
+
// Schema builder (no-code entity-type/field builder — startsim-768w.10.4 S2)
|
|
96
|
+
export * from './schema'
|
|
97
|
+
|
|
95
98
|
// Compose flow (auto-save, header, status, confirmation dialog)
|
|
96
99
|
export * from './compose'
|
|
97
100
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Plus, Trash2 } from 'lucide-react'
|
|
5
|
+
import { Button } from '../ui/button'
|
|
6
|
+
import { Input } from '../ui/input'
|
|
7
|
+
import { Label } from '../ui/label'
|
|
8
|
+
import { Checkbox } from '../ui/checkbox'
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
} from '../ui/select'
|
|
17
|
+
import { notify as defaultNotify } from '../toast'
|
|
18
|
+
import { cn } from '../../lib/utils'
|
|
19
|
+
import {
|
|
20
|
+
type DataType,
|
|
21
|
+
type DraftAttribute,
|
|
22
|
+
type AttributePayload,
|
|
23
|
+
DATA_TYPES,
|
|
24
|
+
blankAttribute,
|
|
25
|
+
toKey,
|
|
26
|
+
draftToAttributePayloads,
|
|
27
|
+
} from './schema-builder-core'
|
|
28
|
+
|
|
29
|
+
/** The type the backend echoes back after creation (only `id` is required). */
|
|
30
|
+
export interface CreatedType {
|
|
31
|
+
id: number
|
|
32
|
+
key: string
|
|
33
|
+
label: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A minimal notifier surface; defaults to @startsimpli/ui's toast notify. */
|
|
37
|
+
export interface SchemaTypeBuilderNotifier {
|
|
38
|
+
success?: (message: string) => void
|
|
39
|
+
error?: (message: string) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Theme slots so a consuming app can restyle the builder without forking it. */
|
|
43
|
+
export interface SchemaTypeBuilderClassNames {
|
|
44
|
+
root?: string
|
|
45
|
+
card?: string
|
|
46
|
+
fieldRow?: string
|
|
47
|
+
submit?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SchemaTypeBuilderProps {
|
|
51
|
+
/** 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. */
|
|
54
|
+
createAttribute: (input: AttributePayload) => Promise<unknown>
|
|
55
|
+
/** Fired after the type and all its attributes are created (navigate / refetch here). */
|
|
56
|
+
onCreated?: (created: CreatedType) => void
|
|
57
|
+
/** Inject a notifier; defaults to the shared @startsimpli/ui notify. */
|
|
58
|
+
notify?: SchemaTypeBuilderNotifier
|
|
59
|
+
/** Override the submit label (default "Create type"). */
|
|
60
|
+
submitLabel?: string
|
|
61
|
+
classNames?: SchemaTypeBuilderClassNames
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 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`.
|
|
70
|
+
*/
|
|
71
|
+
export function SchemaTypeBuilder({
|
|
72
|
+
createType,
|
|
73
|
+
createAttribute,
|
|
74
|
+
onCreated,
|
|
75
|
+
notify = defaultNotify,
|
|
76
|
+
submitLabel = 'Create type',
|
|
77
|
+
classNames,
|
|
78
|
+
}: SchemaTypeBuilderProps) {
|
|
79
|
+
const [label, setLabel] = useState('')
|
|
80
|
+
const [keyOverride, setKeyOverride] = useState('')
|
|
81
|
+
const [attributes, setAttributes] = useState<DraftAttribute[]>([blankAttribute()])
|
|
82
|
+
const [saving, setSaving] = useState(false)
|
|
83
|
+
|
|
84
|
+
const effectiveKey = keyOverride.trim() || toKey(label)
|
|
85
|
+
|
|
86
|
+
function updateAttr(index: number, patch: Partial<DraftAttribute>) {
|
|
87
|
+
setAttributes((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function addAttr() {
|
|
91
|
+
setAttributes((prev) => [...prev, blankAttribute()])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function removeAttr(index: number) {
|
|
95
|
+
setAttributes((prev) => prev.filter((_, i) => i !== index))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
if (!label.trim() || !effectiveKey) {
|
|
101
|
+
notify.error?.('Give your type a name.')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
setSaving(true)
|
|
105
|
+
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)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
notify.success?.(`Created "${label.trim()}".`)
|
|
115
|
+
onCreated?.({ id: type.id, key: effectiveKey, label: label.trim() })
|
|
116
|
+
} catch (err) {
|
|
117
|
+
notify.error?.(err instanceof Error ? err.message : 'Could not create the type.')
|
|
118
|
+
} finally {
|
|
119
|
+
setSaving(false)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<form onSubmit={handleSubmit} className={cn('space-y-6', classNames?.root)}>
|
|
125
|
+
<Card className={classNames?.card}>
|
|
126
|
+
<CardHeader>
|
|
127
|
+
<CardTitle>Type details</CardTitle>
|
|
128
|
+
</CardHeader>
|
|
129
|
+
<CardContent className="space-y-4">
|
|
130
|
+
<div className="space-y-1.5">
|
|
131
|
+
<Label htmlFor="type-label">Name</Label>
|
|
132
|
+
<Input
|
|
133
|
+
id="type-label"
|
|
134
|
+
value={label}
|
|
135
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
136
|
+
placeholder="e.g. Customer, Invoice, Property"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="space-y-1.5">
|
|
140
|
+
<Label htmlFor="type-key">Key (optional)</Label>
|
|
141
|
+
<Input
|
|
142
|
+
id="type-key"
|
|
143
|
+
value={keyOverride}
|
|
144
|
+
onChange={(e) => setKeyOverride(e.target.value)}
|
|
145
|
+
placeholder={toKey(label) || 'auto-generated from the name'}
|
|
146
|
+
/>
|
|
147
|
+
<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>.
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
</CardContent>
|
|
153
|
+
</Card>
|
|
154
|
+
|
|
155
|
+
<Card className={classNames?.card}>
|
|
156
|
+
<CardHeader>
|
|
157
|
+
<CardTitle>Fields</CardTitle>
|
|
158
|
+
</CardHeader>
|
|
159
|
+
<CardContent className="space-y-4">
|
|
160
|
+
{attributes.map((attr, i) => (
|
|
161
|
+
<div
|
|
162
|
+
key={i}
|
|
163
|
+
className={cn(
|
|
164
|
+
'grid items-end gap-3 sm:grid-cols-[1fr_180px_auto_auto]',
|
|
165
|
+
classNames?.fieldRow,
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<div className="space-y-1.5">
|
|
169
|
+
<Label htmlFor={`attr-name-${i}`}>Field name</Label>
|
|
170
|
+
<Input
|
|
171
|
+
id={`attr-name-${i}`}
|
|
172
|
+
value={attr.name}
|
|
173
|
+
onChange={(e) => updateAttr(i, { name: e.target.value })}
|
|
174
|
+
placeholder="e.g. email, due_date, amount"
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div className="space-y-1.5">
|
|
179
|
+
<Label>Type</Label>
|
|
180
|
+
<Select
|
|
181
|
+
value={attr.dataType}
|
|
182
|
+
onValueChange={(v) => updateAttr(i, { dataType: v as DataType })}
|
|
183
|
+
>
|
|
184
|
+
<SelectTrigger>
|
|
185
|
+
<SelectValue />
|
|
186
|
+
</SelectTrigger>
|
|
187
|
+
<SelectContent>
|
|
188
|
+
{DATA_TYPES.map((dt) => (
|
|
189
|
+
<SelectItem key={dt.value} value={dt.value}>
|
|
190
|
+
{dt.label}
|
|
191
|
+
</SelectItem>
|
|
192
|
+
))}
|
|
193
|
+
</SelectContent>
|
|
194
|
+
</Select>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="flex items-center gap-2 pb-2.5">
|
|
198
|
+
<Checkbox
|
|
199
|
+
id={`attr-required-${i}`}
|
|
200
|
+
checked={attr.required}
|
|
201
|
+
onCheckedChange={(c) => updateAttr(i, { required: c === true })}
|
|
202
|
+
/>
|
|
203
|
+
<Label htmlFor={`attr-required-${i}`}>Required</Label>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="icon"
|
|
210
|
+
onClick={() => removeAttr(i)}
|
|
211
|
+
aria-label="Remove field"
|
|
212
|
+
disabled={attributes.length === 1}
|
|
213
|
+
>
|
|
214
|
+
<Trash2 className="h-4 w-4" />
|
|
215
|
+
</Button>
|
|
216
|
+
|
|
217
|
+
{attr.dataType === 'enum' && (
|
|
218
|
+
<div className="space-y-1.5 sm:col-span-4">
|
|
219
|
+
<Label htmlFor={`attr-choices-${i}`}>Choices (comma-separated)</Label>
|
|
220
|
+
<Input
|
|
221
|
+
id={`attr-choices-${i}`}
|
|
222
|
+
value={attr.choices}
|
|
223
|
+
onChange={(e) => updateAttr(i, { choices: e.target.value })}
|
|
224
|
+
placeholder="e.g. Open, In progress, Done"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{(attr.dataType === 'number' || attr.dataType === 'integer') && (
|
|
230
|
+
<div className="grid gap-3 sm:col-span-4 sm:grid-cols-2">
|
|
231
|
+
<div className="space-y-1.5">
|
|
232
|
+
<Label htmlFor={`attr-min-${i}`}>Minimum (optional)</Label>
|
|
233
|
+
<Input
|
|
234
|
+
id={`attr-min-${i}`}
|
|
235
|
+
type="number"
|
|
236
|
+
value={attr.min}
|
|
237
|
+
onChange={(e) => updateAttr(i, { min: e.target.value })}
|
|
238
|
+
placeholder="e.g. 0"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
<div className="space-y-1.5">
|
|
242
|
+
<Label htmlFor={`attr-max-${i}`}>Maximum (optional)</Label>
|
|
243
|
+
<Input
|
|
244
|
+
id={`attr-max-${i}`}
|
|
245
|
+
type="number"
|
|
246
|
+
value={attr.max}
|
|
247
|
+
onChange={(e) => updateAttr(i, { max: e.target.value })}
|
|
248
|
+
placeholder="e.g. 100"
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{(attr.dataType === 'text' || attr.dataType === 'longtext') && (
|
|
255
|
+
<div className="space-y-1.5 sm:col-span-4">
|
|
256
|
+
<Label htmlFor={`attr-regex-${i}`}>Pattern (regex, optional)</Label>
|
|
257
|
+
<Input
|
|
258
|
+
id={`attr-regex-${i}`}
|
|
259
|
+
value={attr.regex}
|
|
260
|
+
onChange={(e) => updateAttr(i, { regex: e.target.value })}
|
|
261
|
+
placeholder="e.g. [^@]+@[^@]+\.[^@]+"
|
|
262
|
+
/>
|
|
263
|
+
<p className="text-xs text-gray-500">
|
|
264
|
+
Values must fully match this pattern.
|
|
265
|
+
</p>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
))}
|
|
270
|
+
|
|
271
|
+
<Button type="button" variant="outline" onClick={addAttr}>
|
|
272
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
273
|
+
Add field
|
|
274
|
+
</Button>
|
|
275
|
+
</CardContent>
|
|
276
|
+
</Card>
|
|
277
|
+
|
|
278
|
+
<div className="flex justify-end">
|
|
279
|
+
<Button type="submit" disabled={saving} className={classNames?.submit}>
|
|
280
|
+
{saving ? 'Creating…' : submitLabel}
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
283
|
+
</form>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { SchemaTypeBuilder } from '../SchemaTypeBuilder'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Light render coverage for the promoted, API-injected builder. We exercise the
|
|
7
|
+
* default text-field path only — Radix Select interaction in jsdom is a known
|
|
8
|
+
* rabbit hole, and the dataType/enum branching is covered exhaustively by the
|
|
9
|
+
* pure draftToAttributePayloads specs.
|
|
10
|
+
*/
|
|
11
|
+
function setup() {
|
|
12
|
+
const createType = jest.fn().mockResolvedValue({ id: 42 })
|
|
13
|
+
const createAttribute = jest.fn().mockResolvedValue({ id: 1 })
|
|
14
|
+
const onCreated = jest.fn()
|
|
15
|
+
const notify = { success: jest.fn(), error: jest.fn() }
|
|
16
|
+
render(
|
|
17
|
+
<SchemaTypeBuilder
|
|
18
|
+
createType={createType}
|
|
19
|
+
createAttribute={createAttribute}
|
|
20
|
+
onCreated={onCreated}
|
|
21
|
+
notify={notify}
|
|
22
|
+
/>,
|
|
23
|
+
)
|
|
24
|
+
return { createType, createAttribute, onCreated, notify, user: userEvent.setup() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('SchemaTypeBuilder', () => {
|
|
28
|
+
it('renders the type-name field and a create button without any foundry-api import', () => {
|
|
29
|
+
setup()
|
|
30
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getByRole('button', { name: /create type/i })).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('submits the injected createType + createAttribute with the slugified key and trimmed field name', async () => {
|
|
35
|
+
const { createType, createAttribute, onCreated, user } = setup()
|
|
36
|
+
|
|
37
|
+
await user.type(screen.getByLabelText('Name'), 'Customer')
|
|
38
|
+
await user.type(screen.getByLabelText('Field name'), 'email')
|
|
39
|
+
await user.click(screen.getByRole('button', { name: /create type/i }))
|
|
40
|
+
|
|
41
|
+
await waitFor(() => expect(createType).toHaveBeenCalledTimes(1))
|
|
42
|
+
expect(createType).toHaveBeenCalledWith({ key: 'customer', label: 'Customer' })
|
|
43
|
+
expect(createAttribute).toHaveBeenCalledWith({
|
|
44
|
+
entityType: 42,
|
|
45
|
+
name: 'email',
|
|
46
|
+
dataType: 'text',
|
|
47
|
+
required: false,
|
|
48
|
+
config: {},
|
|
49
|
+
})
|
|
50
|
+
expect(onCreated).toHaveBeenCalledWith({ id: 42, key: 'customer', label: 'Customer' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('refuses to submit (and warns) when no name is given', async () => {
|
|
54
|
+
const { createType, notify, user } = setup()
|
|
55
|
+
await user.click(screen.getByRole('button', { name: /create type/i }))
|
|
56
|
+
expect(createType).not.toHaveBeenCalled()
|
|
57
|
+
expect(notify.error).toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
toKey,
|
|
3
|
+
blankAttribute,
|
|
4
|
+
draftToAttributePayloads,
|
|
5
|
+
DATA_TYPES,
|
|
6
|
+
type DraftAttribute,
|
|
7
|
+
} from '../schema-builder-core'
|
|
8
|
+
|
|
9
|
+
describe('toKey', () => {
|
|
10
|
+
it('slugifies a label into a stable lowercase key', () => {
|
|
11
|
+
expect(toKey('Customer')).toBe('customer')
|
|
12
|
+
expect(toKey('Purchase Order')).toBe('purchase_order')
|
|
13
|
+
expect(toKey(' Invoice #2024 ')).toBe('invoice_2024')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('collapses runs of non-alphanumerics and trims edge underscores', () => {
|
|
17
|
+
expect(toKey('A -- B')).toBe('a_b')
|
|
18
|
+
expect(toKey('***weird***')).toBe('weird')
|
|
19
|
+
expect(toKey('')).toBe('')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('blankAttribute', () => {
|
|
24
|
+
it('defaults to a non-required text field with no choices/bounds/regex', () => {
|
|
25
|
+
expect(blankAttribute()).toEqual({
|
|
26
|
+
name: '',
|
|
27
|
+
dataType: 'text',
|
|
28
|
+
required: false,
|
|
29
|
+
choices: '',
|
|
30
|
+
min: '',
|
|
31
|
+
max: '',
|
|
32
|
+
regex: '',
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('draftToAttributePayloads', () => {
|
|
38
|
+
const text = (over: Partial<DraftAttribute> = {}): DraftAttribute => ({
|
|
39
|
+
...blankAttribute(),
|
|
40
|
+
name: 'email',
|
|
41
|
+
...over,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('drops blank-name rows and trims names', () => {
|
|
45
|
+
const payloads = draftToAttributePayloads(7, [
|
|
46
|
+
text({ name: ' email ' }),
|
|
47
|
+
text({ name: '' }),
|
|
48
|
+
text({ name: ' ' }),
|
|
49
|
+
])
|
|
50
|
+
expect(payloads).toHaveLength(1)
|
|
51
|
+
expect(payloads[0]).toMatchObject({ entityType: 7, name: 'email', config: {} })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('builds enum config.choices from the comma-separated input (trimmed, empties removed)', () => {
|
|
55
|
+
const [payload] = draftToAttributePayloads(3, [
|
|
56
|
+
text({ name: 'status', dataType: 'enum', choices: 'Open, In progress , ,Done' }),
|
|
57
|
+
])
|
|
58
|
+
expect(payload).toMatchObject({
|
|
59
|
+
entityType: 3,
|
|
60
|
+
name: 'status',
|
|
61
|
+
dataType: 'enum',
|
|
62
|
+
config: { choices: ['Open', 'In progress', 'Done'] },
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('carries the required flag and uses an empty config for non-enum fields', () => {
|
|
67
|
+
const [payload] = draftToAttributePayloads(1, [
|
|
68
|
+
text({ name: 'amount', dataType: 'number', required: true }),
|
|
69
|
+
])
|
|
70
|
+
expect(payload).toEqual({
|
|
71
|
+
entityType: 1,
|
|
72
|
+
name: 'amount',
|
|
73
|
+
dataType: 'number',
|
|
74
|
+
required: true,
|
|
75
|
+
config: {},
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('builds numeric config.min/max from the min/max inputs (number/integer)', () => {
|
|
80
|
+
const [payload] = draftToAttributePayloads(5, [
|
|
81
|
+
text({ name: 'qty', dataType: 'integer', min: '1', max: '10' }),
|
|
82
|
+
])
|
|
83
|
+
expect(payload).toMatchObject({
|
|
84
|
+
name: 'qty',
|
|
85
|
+
dataType: 'integer',
|
|
86
|
+
config: { min: 1, max: 10 },
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('sets only the numeric bounds that are provided (blank min/max omitted)', () => {
|
|
91
|
+
const [payload] = draftToAttributePayloads(5, [
|
|
92
|
+
text({ name: 'score', dataType: 'number', min: '0', max: '' }),
|
|
93
|
+
])
|
|
94
|
+
expect(payload.config).toEqual({ min: 0 })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('builds text config.regex from the regex input (text/longtext)', () => {
|
|
98
|
+
const [payload] = draftToAttributePayloads(5, [
|
|
99
|
+
text({ name: 'email', dataType: 'text', regex: '[^@]+@[^@]+' }),
|
|
100
|
+
])
|
|
101
|
+
expect(payload.config).toEqual({ regex: '[^@]+@[^@]+' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keeps config keys scoped to the dataType (no cross-contamination)', () => {
|
|
105
|
+
// text reads only regex; a stray min/max/choices must not leak into config.
|
|
106
|
+
const [payload] = draftToAttributePayloads(5, [
|
|
107
|
+
text({ name: 'note', dataType: 'text', min: '1', max: '9', choices: 'a,b' }),
|
|
108
|
+
])
|
|
109
|
+
expect(payload.config).toEqual({})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('DATA_TYPES', () => {
|
|
114
|
+
it('exposes the eight backend data types as {value,label} options', () => {
|
|
115
|
+
expect(DATA_TYPES.map((d) => d.value)).toEqual([
|
|
116
|
+
'text',
|
|
117
|
+
'longtext',
|
|
118
|
+
'number',
|
|
119
|
+
'integer',
|
|
120
|
+
'boolean',
|
|
121
|
+
'date',
|
|
122
|
+
'enum',
|
|
123
|
+
'json',
|
|
124
|
+
])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { SchemaTypeBuilder } from './SchemaTypeBuilder'
|
|
2
|
+
export type {
|
|
3
|
+
SchemaTypeBuilderProps,
|
|
4
|
+
SchemaTypeBuilderClassNames,
|
|
5
|
+
SchemaTypeBuilderNotifier,
|
|
6
|
+
CreatedType,
|
|
7
|
+
} from './SchemaTypeBuilder'
|
|
8
|
+
export {
|
|
9
|
+
DATA_TYPES,
|
|
10
|
+
blankAttribute,
|
|
11
|
+
toKey,
|
|
12
|
+
draftToAttributePayloads,
|
|
13
|
+
} from './schema-builder-core'
|
|
14
|
+
export type { DataType, DraftAttribute, AttributePayload } from './schema-builder-core'
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, render-free helpers for the no-code SchemaTypeBuilder. Promoted into
|
|
3
|
+
* packages/ui (startsim-768w.10.4 S2) from frontend-starter's type-builder-core
|
|
4
|
+
* so the bug-prone bits — slugifying a label into a stable key and turning draft
|
|
5
|
+
* field rows into createAttribute() payloads (enum config, blank-row skipping) —
|
|
6
|
+
* are unit-testable without rendering and reusable by any consuming app.
|
|
7
|
+
*
|
|
8
|
+
* The eight DataType values mirror the tenant-starter AttributeDef.data_type
|
|
9
|
+
* choices (apps/schema_registry/models.py). Keep them in lockstep with the
|
|
10
|
+
* backend; the builder offers exactly these.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DataType =
|
|
14
|
+
| 'text'
|
|
15
|
+
| 'longtext'
|
|
16
|
+
| 'number'
|
|
17
|
+
| 'integer'
|
|
18
|
+
| 'boolean'
|
|
19
|
+
| 'date'
|
|
20
|
+
| 'enum'
|
|
21
|
+
| 'json'
|
|
22
|
+
|
|
23
|
+
export const DATA_TYPES: { value: DataType; label: string }[] = [
|
|
24
|
+
{ value: 'text', label: 'Short text' },
|
|
25
|
+
{ value: 'longtext', label: 'Long text' },
|
|
26
|
+
{ value: 'number', label: 'Number (decimal)' },
|
|
27
|
+
{ value: 'integer', label: 'Whole number' },
|
|
28
|
+
{ value: 'boolean', label: 'Yes / No' },
|
|
29
|
+
{ value: 'date', label: 'Date' },
|
|
30
|
+
{ value: 'enum', label: 'Choice (pick one)' },
|
|
31
|
+
{ value: 'json', label: 'Structured (JSON)' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export interface DraftAttribute {
|
|
35
|
+
name: string
|
|
36
|
+
dataType: DataType
|
|
37
|
+
required: boolean
|
|
38
|
+
/** Comma-separated choices, only used when dataType === 'enum'. */
|
|
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
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function blankAttribute(): DraftAttribute {
|
|
49
|
+
return { name: '', dataType: 'text', required: false, choices: '', min: '', max: '', regex: '' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slugify a label into a stable, lowercase key the backend stores. */
|
|
53
|
+
export function toKey(label: string): string {
|
|
54
|
+
return label
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
58
|
+
.replace(/^_+|_+$/g, '')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AttributePayload {
|
|
62
|
+
entityType: number
|
|
63
|
+
name: string
|
|
64
|
+
dataType: DataType
|
|
65
|
+
required: boolean
|
|
66
|
+
config: Record<string, unknown>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turn the draft field rows into createAttribute() inputs for a just-created
|
|
71
|
+
* type: drop blank-name rows, trim names, and build enum `config.choices` from
|
|
72
|
+
* the comma-separated input (trimmed, empties removed).
|
|
73
|
+
*/
|
|
74
|
+
export function draftToAttributePayloads(
|
|
75
|
+
entityTypeId: number,
|
|
76
|
+
drafts: DraftAttribute[],
|
|
77
|
+
): AttributePayload[] {
|
|
78
|
+
const out: AttributePayload[] = []
|
|
79
|
+
for (const attr of drafts) {
|
|
80
|
+
const name = attr.name.trim()
|
|
81
|
+
if (!name) continue
|
|
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
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|