/** * Pure, render-free helpers for the no-code SchemaTypeBuilder. Promoted into * packages/ui (startsim-768w.10.4 S2) from frontend-starter's type-builder-core * so the bug-prone bits — slugifying a label into a stable key and turning draft * field rows into createAttribute() payloads (enum config, blank-row skipping) — * are unit-testable without rendering and reusable by any consuming app. * * The eight DataType values mirror the tenant-starter AttributeDef.data_type * choices (apps/schema_registry/models.py). Keep them in lockstep with the * backend; the builder offers exactly these. */ export type DataType = | 'text' | 'longtext' | 'number' | 'integer' | 'boolean' | 'date' | 'enum' | 'json' export const DATA_TYPES: { value: DataType; label: string }[] = [ { value: 'text', label: 'Short text' }, { value: 'longtext', label: 'Long text' }, { value: 'number', label: 'Number (decimal)' }, { value: 'integer', label: 'Whole number' }, { value: 'boolean', label: 'Yes / No' }, { value: 'date', label: 'Date' }, { value: 'enum', label: 'Choice (pick one)' }, { value: 'json', label: 'Structured (JSON)' }, ] export interface DraftAttribute { name: string dataType: DataType required: boolean /** Comma-separated choices, only used when dataType === 'enum'. */ choices: string /** Lower bound, only used when dataType is number/integer (blank = none). */ min: string /** Upper bound, only used when dataType is number/integer (blank = none). */ max: string /** Validation pattern, only used when dataType is text/longtext (blank = none). */ regex: string } export function blankAttribute(): DraftAttribute { return { name: '', dataType: 'text', required: false, choices: '', min: '', max: '', regex: '' } } /** Slugify a label into a stable, lowercase key the backend stores. */ export function toKey(label: string): string { return label .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') } /** Suggest a relationship (edge) key from its endpoint type names. Empty until * both ends are chosen. */ export function suggestRelationshipKey(source: string, target: string): string { if (!source.trim() || !target.trim()) return '' return toKey(`${source}_${target}`) } /** Server-assigned ids are UUID strings; numbers are tolerated for tests/legacy. */ export type SchemaId = string | number export interface AttributePayload { entityType: SchemaId name: string dataType: DataType required: boolean config: Record } /** Build the dataType-scoped config for one draft row. Keys match what the * backend enforces (enum: choices, number/integer: min/max, text/longtext: * regex). Numeric bounds + regex are omitted when blank; an enum always carries * its choices. */ function buildConfig(attr: DraftAttribute): Record { const config: Record = {} if (attr.dataType === 'enum') { config.choices = attr.choices .split(',') .map((c) => c.trim()) .filter(Boolean) } else if (attr.dataType === 'number' || attr.dataType === 'integer') { const min = Number(attr.min) const max = Number(attr.max) if (attr.min.trim() !== '' && !Number.isNaN(min)) config.min = min if (attr.max.trim() !== '' && !Number.isNaN(max)) config.max = max } else if (attr.dataType === 'text' || attr.dataType === 'longtext') { if (attr.regex.trim() !== '') config.regex = attr.regex.trim() } return config } /** One draft row → a createAttribute() payload, or null for a blank-name row. */ export function draftToPayload( entityTypeId: SchemaId, attr: DraftAttribute, ): AttributePayload | null { const name = attr.name.trim() if (!name) return null return { entityType: entityTypeId, name, dataType: attr.dataType, required: attr.required, config: buildConfig(attr) } } /** * Turn the draft field rows into createAttribute() inputs for a just-created * type: drop blank-name rows, trim names, and build the dataType-scoped config. */ export function draftToAttributePayloads( entityTypeId: SchemaId, drafts: DraftAttribute[], ): AttributePayload[] { const out: AttributePayload[] = [] for (const attr of drafts) { const payload = draftToPayload(entityTypeId, attr) if (payload) out.push(payload) } return out } // ---- edit mode (S4): hydrate existing attributes + diff against drafts ---- /** A persisted attribute as the schema API returns it (camelCase via the client). */ export interface ExistingAttribute { id: SchemaId name: string dataType: DataType required: boolean config: Record } /** A draft row that may correspond to a persisted attribute (carries its id). */ export interface EditableDraft extends DraftAttribute { id?: SchemaId } /** Inverse of draftToPayload: hydrate an editable row from a saved attribute, * back-filling the dataType-scoped config into its string inputs. */ export function attributeToDraft(attr: ExistingAttribute): EditableDraft { const cfg = attr.config || {} const choices = Array.isArray(cfg.choices) ? (cfg.choices as unknown[]).join(', ') : '' const min = cfg.min !== undefined && cfg.min !== null ? String(cfg.min) : '' const max = cfg.max !== undefined && cfg.max !== null ? String(cfg.max) : '' const regex = typeof cfg.regex === 'string' ? cfg.regex : '' return { id: attr.id, name: attr.name, dataType: attr.dataType, required: attr.required, choices, min, max, regex, } } /** A single attribute update (the id + the writable payload fields). */ export type AttributeUpdate = { id: SchemaId } & Omit export interface AttributeEdits { creates: AttributePayload[] updates: AttributeUpdate[] deletes: SchemaId[] } function sameConfig(a: Record, b: Record): boolean { const ak = Object.keys(a).sort() const bk = Object.keys(b).sort() if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false return ak.every((k) => JSON.stringify(a[k]) === JSON.stringify(b[k])) } /** * Reconcile the edited draft rows against the type's persisted attributes into * the API calls needed: id-less rows -> creates, changed existing rows -> * updates, and originals whose row was removed (or blanked) -> deletes. */ export function diffAttributes( entityTypeId: SchemaId, originals: ExistingAttribute[], drafts: EditableDraft[], ): AttributeEdits { const creates: AttributePayload[] = [] const updates: AttributeUpdate[] = [] const keptIds = new Set() for (const draft of drafts) { const payload = draftToPayload(entityTypeId, draft) if (!payload) continue // blank-name row: a new blank is ignored; a blanked existing falls through to delete if (draft.id == null) { creates.push(payload) continue } keptIds.add(draft.id) const orig = originals.find((o) => o.id === draft.id) const changed = !orig || orig.name !== payload.name || orig.dataType !== payload.dataType || orig.required !== payload.required || !sameConfig(orig.config || {}, payload.config) if (changed) { updates.push({ id: draft.id, name: payload.name, dataType: payload.dataType, required: payload.required, config: payload.config }) } } const deletes = originals.filter((o) => !keptIds.has(o.id)).map((o) => o.id) return { creates, updates, deletes } }