@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.33",
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/auth": "0.4.27",
37
- "@startsimpli/api": "0.5.33"
36
+ "@startsimpli/api": "0.5.35",
37
+ "@startsimpli/auth": "0.4.28"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@startsimpli/api": {
@@ -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
+ }