feat(web): key/label split + slugification in collection modals (TASK-595) (#134)

* feat(web): add key/label split + slugification to collection modals

Closes TASK-595 in PLAN-593.

Replace the Create modal's stripped-down row form with the shared
FieldEditor component from TASK-594 and introduce a proper key/label
split with auto-slugification, duplicate detection, and reserved-key
validation.

New UI
- FieldEditor shows a muted monospace Key input under the Label input
  for new (unsaved) fields only. The key auto-syncs from the label
  via slugify(). Once the user edits the key manually (keyTouched),
  auto-sync stops and an inline hint explains that keys are immutable
  after save.
- Inline error message + red outline on the key input when the key is
  invalid or collides with another field. Create/Save buttons are
  disabled (with a reason tooltip) while any field has a blocking
  error.

Shared helpers in field-editor-types.ts
- slugifyKey(): lowercase, strip non-[a-z0-9_\s-], collapse whitespace
  and hyphens to underscores, trim, max 40 chars.
- RESERVED_FIELD_KEYS: UI-side reserved list mirroring top-level Item
  JSON fields (id, slug, ref, title, content, created_at, etc.) that
  would shadow core item properties and cause confusion.
- validateFieldKey(): structural validation (non-empty, starts with a
  letter, only lowercase+digits+underscore, not reserved, length).
- fieldFromDef(): hydrate an EditableField from a FieldDef preserving
  the key verbatim — used when loading templates or existing fields
  so slugify doesn't overwrite already-valid keys.

CreateCollectionModal
- Drops the { key, type, options: string } row form.
- Now renders fields via FieldEditor bound to EditableField[].
- Template selection marks loaded fields keyTouched=true so template
  keys stay intact. Duplicate / reserved / empty keys disable Create
  with a hover tooltip reason.
- Save uses field.key directly (no longer derives key from label at
  submit time).

EditCollectionModal
- New fields gain the same key/label split + validation. Duplicate
  detection runs against existing field keys + other new-field keys.
- hasNewFieldBlockingErrors gates the Save button with a tooltip.
- Existing-field behavior is unchanged: label editable, key frozen,
  no key row rendered.

Behavior / regression notes
- User-visible outcome for a typed-and-submitted collection is now:
  label "Target Date" -> key "target_date" (was "Target Date" in
  pre-T2 behavior). This is the intended fix.
- Templates continue to ship with their existing keys verbatim.
- Alignment of the type dropdown against the taller new-field card
  will be revisited in TASK-598 (visual redesign pass).

* fix(web): persist terminal_options on newly-created status fields

Codex P2 (PR #134): the Create modal and EditCollectionModal's new-
fields serialization paths copied key/label/type/options into FieldDef
but not terminal_options. Users could toggle terminal markings on a
status field in the FieldEditor, click Create (or Save), and have
those choices silently dropped — making terminal-dependent behavior
fall back to defaults and misclassify statuses.

Mirror the existing-fields branch that already handles this in
EditCollectionModal: when the field key is "status" and terminalOptions
is non-empty, filter to the options that survived in the saved set and
write them to def.terminal_options.

Key === "status" gating matches the current FieldEditor UI. T4
(TASK-597) will generalize terminal options to any select field and
the gate can be removed there.
This commit is contained in:
xarmian
2026-04-17 13:18:02 -04:00
committed by GitHub
parent ecea75dcd8
commit c9f16a04ea
4 changed files with 430 additions and 167 deletions
@@ -3,6 +3,13 @@
import type { CollectionCreate, FieldDef, CollectionSettings } from '$lib/types';
import { COLLECTION_TEMPLATES, type CollectionTemplate } from './collection-templates';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import FieldEditor from './FieldEditor.svelte';
import {
blankField,
fieldFromDef,
validateFieldKey,
type EditableField
} from './field-editor-types';
import { toastStore } from '$lib/stores/toast.svelte';
interface Props {
@@ -14,17 +21,6 @@
let { open, wsSlug, oncreated, onclose }: Props = $props();
const FIELD_TYPES: FieldDef['type'][] = [
'text',
'number',
'select',
'multi_select',
'date',
'checkbox',
'url',
'relation'
];
// Step state: 'templates' or 'editor'
let step = $state<'templates' | 'editor'>('templates');
@@ -33,7 +29,7 @@
let selectedIcon = $state('');
let description = $state('');
let showEmojiPicker = $state(false);
let fields = $state<{ key: string; type: FieldDef['type']; options: string }[]>([]);
let fields = $state<EditableField[]>([]);
let selectedSettings = $state<CollectionSettings | null>(null);
let creating = $state(false);
let error = $state('');
@@ -73,11 +69,9 @@
selectedIcon = template.icon;
description = template.description;
selectedSettings = { ...template.settings };
fields = template.fields.map((f) => ({
key: f.key,
type: f.type,
options: f.options ? f.options.join(', ') : ''
}));
// Template fields already have valid keys — use fieldFromDef with
// existing=true so keyTouched=true and slugify doesn't overwrite.
fields = template.fields.map((f) => fieldFromDef(f, true));
}
step = 'editor';
}
@@ -88,27 +82,95 @@
}
function addField() {
fields.push({ key: '', type: 'text', options: '' });
fields.push(blankField());
}
function removeField(index: number) {
fields.splice(index, 1);
}
function moveField(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= fields.length) return;
const temp = fields[index];
fields[index] = fields[target];
fields[target] = temp;
}
// ── Key validation (per-field + cross-field duplicate detection) ────────
/**
* Compute a per-field key error, or null if the key is valid.
* - null: the field is empty (no label typed yet) — don't show an error,
* but `hasBlockingErrors` still treats it as incomplete so Create is
* disabled until the user fills it in.
* - a string: structural error (reserved, bad chars) or duplicate.
*/
const keyErrors = $derived.by(() => {
const errors: (string | null)[] = [];
// Count occurrences of each non-empty key across all fields, so we
// can flag duplicates.
const counts = new Map<string, number>();
for (const f of fields) {
const k = f.key.trim();
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
}
for (const f of fields) {
// Skip empty fields — user is still typing.
if (!f.label.trim() && !f.key.trim()) {
errors.push(null);
continue;
}
const structural = validateFieldKey(f.key);
if (structural) {
errors.push(structural);
continue;
}
if ((counts.get(f.key.trim()) ?? 0) > 1) {
errors.push(`Duplicate key "${f.key.trim()}"`);
continue;
}
errors.push(null);
}
return errors;
});
/** True if any field has a key error OR is partially filled (one of label/key empty). */
const hasBlockingErrors = $derived.by(() => {
if (keyErrors.some((e) => e !== null)) return true;
// Any field with a label but no valid key, or vice versa, blocks save.
for (const f of fields) {
const hasLabel = !!f.label.trim();
const hasKey = !!f.key.trim();
if (hasLabel !== hasKey) return true;
}
return false;
});
async function handleCreate() {
if (!name.trim() || creating) return;
if (!name.trim() || creating || hasBlockingErrors) return;
creating = true;
error = '';
try {
const fieldDefs: FieldDef[] = fields
.filter((f) => f.key.trim())
.map((f) => {
const def: FieldDef = { key: f.key.trim(), label: f.key.trim(), type: f.type };
if ((f.type === 'select' || f.type === 'multi_select') && f.options.trim()) {
def.options = f.options
.split(',')
.map((o) => o.trim())
.filter(Boolean);
const key = f.key.trim();
const label = f.label.trim() || key;
const def: FieldDef = { key, label, type: f.type };
const opts = f.options.map((o) => o.trim()).filter(Boolean);
if ((f.type === 'select' || f.type === 'multi_select') && opts.length > 0) {
def.options = opts;
}
// Persist terminal-option markings for status fields. Templates
// may ship with terminal_options, and FieldEditor lets users
// toggle them on a status field during create. Without this
// the choices are silently dropped on save. Gated on
// `key === 'status'` to mirror the current UI; T4 generalizes
// this to any select field.
if (key === 'status' && f.terminalOptions.length > 0 && def.options) {
const terms = f.terminalOptions.filter((t) => def.options!.includes(t));
if (terms.length > 0) def.terminal_options = terms;
}
return def;
});
@@ -231,41 +293,24 @@
/>
<div class="fields-section">
<div class="fields-header">
<span class="fields-label">Fields</span>
<button class="add-field-btn" type="button" onclick={addField}>+ Add</button>
</div>
{#each fields as field, i (i)}
<div class="field-row">
<input
class="field-name-input"
type="text"
placeholder="Field name"
bind:value={field.key}
/>
<select class="field-type-select" bind:value={field.type}>
{#each FIELD_TYPES as ft (ft)}
<option value={ft}>{ft.replace('_', ' ')}</option>
{/each}
</select>
{#if field.type === 'select' || field.type === 'multi_select'}
<input
class="field-options-input"
type="text"
placeholder="option1, option2, ..."
bind:value={field.options}
<span class="fields-label">Fields</span>
{#if fields.length > 0}
<div class="fields-list">
{#each fields as _field, i (i)}
<FieldEditor
bind:field={fields[i]}
index={i}
total={fields.length}
isNew
keyError={keyErrors[i]}
onmoveup={() => moveField(i, -1)}
onmovedown={() => moveField(i, 1)}
onremove={() => removeField(i)}
/>
{/if}
<button
class="remove-field-btn"
type="button"
onclick={() => removeField(i)}
>
&#10005;
</button>
{/each}
</div>
{/each}
{/if}
<button class="add-field-btn" type="button" onclick={addField}>+ Add field</button>
</div>
</div>
@@ -275,7 +320,12 @@
class="btn-create"
type="button"
onclick={handleCreate}
disabled={!name.trim() || creating}
disabled={!name.trim() || creating || hasBlockingErrors}
title={hasBlockingErrors
? 'Resolve the field errors before creating'
: !name.trim()
? 'Collection name is required'
: ''}
>
{creating ? 'Creating...' : 'Create Collection'}
</button>
@@ -540,12 +590,6 @@
gap: var(--space-2);
}
.fields-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fields-label {
font-size: 0.85em;
font-weight: 600;
@@ -554,99 +598,29 @@
letter-spacing: 0.04em;
}
.fields-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.add-field-btn {
margin-top: var(--space-1);
background: none;
border: none;
border: 1px dashed var(--border);
color: var(--accent-blue);
font-size: 0.85em;
font-weight: 500;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
width: 100%;
text-align: center;
}
.add-field-btn:hover {
background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
}
.field-row {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.field-name-input {
flex: 1;
min-width: 120px;
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius);
font-size: 0.85em;
color: var(--text-primary);
}
.field-name-input:hover {
border-color: var(--border);
}
.field-name-input:focus {
background: color-mix(in srgb, var(--accent-blue) 8%, transparent);
border-color: var(--accent-blue);
outline: none;
}
.field-type-select {
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius);
font-size: 0.85em;
color: var(--text-primary);
cursor: pointer;
}
.field-type-select:hover {
border-color: var(--border);
}
.field-type-select:focus {
border-color: var(--accent-blue);
outline: none;
}
.field-options-input {
flex: 1 1 100%;
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius);
font-size: 0.85em;
color: var(--text-primary);
}
.field-options-input:hover {
border-color: var(--border);
}
.field-options-input:focus {
border-color: var(--accent-blue);
outline: none;
}
.remove-field-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.85em;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
line-height: 1;
}
.remove-field-btn:hover {
color: var(--accent-red, #ef4444);
background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent);
}
/* -- Footer ------------------------------------------------------------ */
@@ -5,7 +5,11 @@
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import EmojiPickerButton from '$lib/components/common/EmojiPickerButton.svelte';
import FieldEditor from './FieldEditor.svelte';
import { blankField, type EditableField } from './field-editor-types';
import {
blankField,
validateFieldKey,
type EditableField
} from './field-editor-types';
import { toastStore } from '$lib/stores/toast.svelte';
interface Props {
@@ -197,6 +201,56 @@
newFields[target] = temp;
}
// ── New-field key validation ─────────────────────────────────────────────
// Existing field keys are frozen, so validation only runs on newFields.
// A new field's key can collide with:
// - another new field's key (duplicate within the add set)
// - an existing field's key (collision with already-saved schema)
// - a reserved key / structural violation
const newKeyErrors = $derived.by(() => {
const errors: (string | null)[] = [];
const existingKeys = new Set(existingFields.map((f) => f.key.trim()));
const newCounts = new Map<string, number>();
for (const f of newFields) {
const k = f.key.trim();
if (k) newCounts.set(k, (newCounts.get(k) ?? 0) + 1);
}
for (const f of newFields) {
if (!f.label.trim() && !f.key.trim()) {
errors.push(null);
continue;
}
const structural = validateFieldKey(f.key);
if (structural) {
errors.push(structural);
continue;
}
const k = f.key.trim();
if (existingKeys.has(k)) {
errors.push(`Key "${k}" is already used by an existing field`);
continue;
}
if ((newCounts.get(k) ?? 0) > 1) {
errors.push(`Duplicate key "${k}"`);
continue;
}
errors.push(null);
}
return errors;
});
/** True if any new field has a key error or is partially filled. */
const hasNewFieldBlockingErrors = $derived.by(() => {
if (newKeyErrors.some((e) => e !== null)) return true;
for (const f of newFields) {
const hasLabel = !!f.label.trim();
const hasKey = !!f.key.trim();
if (hasLabel !== hasKey) return true;
}
return false;
});
// ── Build migrations ─────────────────────────────────────────────────────
function buildMigrations(): FieldMigration[] {
@@ -226,7 +280,7 @@
// ── Save ─────────────────────────────────────────────────────────────────
async function handleSave() {
if (!name.trim() || saving) return;
if (!name.trim() || saving || hasNewFieldBlockingErrors) return;
saving = true;
error = '';
try {
@@ -252,20 +306,30 @@
return def;
});
// Build new fields
// T1 note: We now share the EditableField shape with existing fields.
// The user-typed value lives in `label`; we use it for both key and
// label to preserve pre-T1 behavior. T2 (TASK-595) will introduce a
// proper key/label split with slugification.
// Build new fields.
// T2: new fields now have a proper key/label split with slugified
// keys. `FieldEditor` auto-syncs key <- slugify(label) until the
// user manually edits the key, at which point the user's value is
// kept verbatim. `newKeyErrors` / `hasNewFieldBlockingErrors`
// prevent save when anything is invalid.
const addedFields: FieldDef[] = newFields
.filter((f) => f.label.trim())
.filter((f) => f.key.trim() && f.label.trim())
.map((f) => {
const name = f.label.trim();
const def: FieldDef = { key: name, label: name, type: f.type };
const key = f.key.trim();
const label = f.label.trim() || key;
const def: FieldDef = { key, label, type: f.type };
const opts = f.options.map((o) => o.trim()).filter(Boolean);
if ((f.type === 'select' || f.type === 'multi_select') && opts.length > 0) {
def.options = opts;
}
// Mirror the existing-fields path: persist terminal-option
// markings for newly-added status fields. Without this,
// choices made via the terminal toggle are silently dropped
// on save.
if (key === 'status' && f.terminalOptions.length > 0 && def.options) {
const terms = f.terminalOptions.filter((t) => def.options!.includes(t));
if (terms.length > 0) def.terminal_options = terms;
}
return def;
});
@@ -438,6 +502,7 @@
index={i}
total={newFields.length}
isNew
keyError={newKeyErrors[i]}
onmoveup={() => moveNewField(i, -1)}
onmovedown={() => moveNewField(i, 1)}
onremove={() => removeNewField(i)}
@@ -602,7 +667,12 @@
class="btn-save"
type="button"
onclick={handleSave}
disabled={!name.trim() || saving}
disabled={!name.trim() || saving || hasNewFieldBlockingErrors}
title={hasNewFieldBlockingErrors
? 'Resolve the new-field errors before saving'
: !name.trim()
? 'Collection name is required'
: ''}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
@@ -1,5 +1,5 @@
<script lang="ts">
import { FIELD_TYPES, type EditableField } from './field-editor-types';
import { FIELD_TYPES, slugifyKey, type EditableField } from './field-editor-types';
interface Props {
/** The field being edited. Mutated in place via bindings. */
@@ -9,11 +9,18 @@
/** Total number of fields in the parent list (used to disable reorder buttons at ends). */
total: number;
/**
* Whether this is a new (unsaved) field. Controls placeholder text only.
* Existing fields have a frozen `key`; new fields get their key derived
* from `label` at save time by the parent.
* Whether this is a new (unsaved) field. Controls:
* - Label input placeholder ("Field name" vs "Field label")
* - Whether the Key input row is shown (new fields only)
* - Whether the key auto-syncs from the label (new + untouched only)
*/
isNew?: boolean;
/**
* Optional inline validation error for the key input. Set by the parent
* modal based on structural validation + duplicate detection. Only
* surfaced when `isNew` is true.
*/
keyError?: string | null;
/** Optional: parent provides a move-up handler. If omitted, the button is hidden. */
onmoveup?: () => void;
/** Optional: parent provides a move-down handler. If omitted, the button is hidden. */
@@ -27,6 +34,7 @@
index,
total,
isNew = false,
keyError = null,
onmoveup,
onmovedown,
onremove
@@ -40,6 +48,21 @@
// Generalizing to any select field is the job of T4 (TASK-597).
const showsTerminalColumn = $derived(field.key === 'status' && field.options.length > 0);
// Auto-derive the key from the label for new fields, unless the user has
// manually edited the key. Once `keyTouched` flips to true the user owns
// the key verbatim. We use explicit handlers rather than a $effect so the
// data flow is visible at the call site.
function onLabelInput() {
if (isNew && !field.keyTouched) {
field.key = slugifyKey(field.label);
}
}
function onKeyInput() {
// Any manual edit in the key input takes ownership — stop auto-sync.
field.keyTouched = true;
}
function removeOption(optIndex: number) {
field.options.splice(optIndex, 1);
}
@@ -89,8 +112,31 @@
class="field-label-input"
type="text"
bind:value={field.label}
oninput={onLabelInput}
placeholder={isNew ? 'Field name' : 'Field label'}
/>
{#if isNew}
<div class="field-key-row">
<span class="field-key-prefix" aria-hidden="true">key</span>
<input
class="field-key-input"
class:has-error={!!keyError}
type="text"
bind:value={field.key}
oninput={onKeyInput}
placeholder="auto-generated from name"
spellcheck="false"
autocomplete="off"
aria-label="Field key"
aria-invalid={!!keyError}
/>
</div>
{#if keyError}
<div class="field-key-error" role="alert">{keyError}</div>
{:else if field.keyTouched}
<div class="field-key-hint">Keys can't be changed after save.</div>
{/if}
{/if}
</div>
<select class="field-type-select" bind:value={field.type} title="Field type">
{#each FIELD_TYPES as ft (ft)}
@@ -267,6 +313,66 @@
outline: none;
}
/* ── Key input row (new fields only) ───────────────────────────────────── */
.field-key-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.field-key-prefix {
font-size: 0.68em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-family: var(--font-mono);
padding: 0 var(--space-2);
}
.field-key-input {
flex: 1;
min-width: 0;
padding: 2px var(--space-2);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 0.78em;
font-family: var(--font-mono);
color: var(--text-muted);
}
.field-key-input:hover {
border-color: var(--border);
background: var(--bg-secondary);
}
.field-key-input:focus {
border-color: var(--accent-blue);
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
}
.field-key-input.has-error {
border-color: var(--accent-red, #ef4444);
background: color-mix(in srgb, var(--accent-red, #ef4444) 4%, transparent);
}
.field-key-error {
padding: 0 var(--space-2);
font-size: 0.72em;
color: var(--accent-red, #ef4444);
}
.field-key-hint {
padding: 0 var(--space-2);
font-size: 0.72em;
color: var(--text-muted);
font-style: italic;
}
.field-type-select {
padding: var(--space-1) var(--space-2);
background: var(--bg-secondary);
@@ -9,10 +9,15 @@ import type { FieldDef } from '$lib/types';
* build rename migrations on save.
* - `terminalOptions` is always an array for easier binding (mirrors
* FieldDef.terminal_options).
* - `keyTouched` is a UI-only flag (not serialized) that tracks whether the
* user has manually edited the key. When false (and the field is new),
* FieldEditor auto-derives the key from the label on every change. Once
* set to true, the auto-sync stops the user has taken control.
*
* Used for both existing fields (loaded from a saved collection) and new
* fields (not yet persisted). For new fields, `key` is typically empty until
* save, at which point the parent modal derives it from `label`.
* fields (not yet persisted). For existing fields the `key` is frozen;
* for new fields the parent modal uses whatever the user ends up with
* in `key` at save time.
*/
export interface EditableField {
key: string;
@@ -26,6 +31,8 @@ export interface EditableField {
collection?: string;
suffix?: string;
default?: unknown;
/** UI-only: true once the user has manually edited the key. */
keyTouched?: boolean;
}
export const FIELD_TYPES: FieldDef['type'][] = [
@@ -39,6 +46,82 @@ export const FIELD_TYPES: FieldDef['type'][] = [
'relation'
];
/**
* Keys reserved at the UI level because they would shadow top-level item
* JSON fields and cause confusion in API responses / rendering.
* The backend does not explicitly reject these, but using them as schema
* field keys is strongly discouraged.
*/
export const RESERVED_FIELD_KEYS: ReadonlySet<string> = new Set([
'id',
'slug',
'ref',
'title',
'content',
'tags',
'pinned',
'sort_order',
'parent_id',
'created_by',
'last_modified_by',
'source',
'created_at',
'updated_at',
'deleted_at',
'fields',
'item_number',
'workspace_id',
'collection_id'
]);
/** Maximum length of a slugified key. */
export const MAX_FIELD_KEY_LENGTH = 40;
/**
* Convert a free-text label into a safe field key.
*
* Rules:
* - lowercase
* - strip leading/trailing whitespace
* - strip any character that isn't [a-z0-9_\s-]
* - collapse any run of whitespace or hyphens to a single underscore
* - collapse consecutive underscores
* - trim leading/trailing underscores
* - truncate to MAX_FIELD_KEY_LENGTH
*/
export function slugifyKey(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9_\s-]/g, '')
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, MAX_FIELD_KEY_LENGTH);
}
/**
* Validate a field key for structural correctness.
* Does NOT check for duplicates the caller must do that since it
* requires context of sibling fields.
*
* @returns null if valid, an error message string otherwise
*/
export function validateFieldKey(key: string): string | null {
const trimmed = key.trim();
if (!trimmed) return 'Key is required';
if (RESERVED_FIELD_KEYS.has(trimmed.toLowerCase())) {
return `"${trimmed}" is a reserved key`;
}
if (!/^[a-z][a-z0-9_]*$/.test(trimmed)) {
return 'Key must start with a letter and contain only lowercase letters, digits, and underscores';
}
if (trimmed.length > MAX_FIELD_KEY_LENGTH) {
return `Key must be ${MAX_FIELD_KEY_LENGTH} characters or fewer`;
}
return null;
}
/** Create an empty EditableField for a new (unsaved) field. */
export function blankField(): EditableField {
return {
@@ -47,6 +130,36 @@ export function blankField(): EditableField {
type: 'text',
options: [],
originalOptions: [],
terminalOptions: []
terminalOptions: [],
keyTouched: false
};
}
/**
* Convert a FieldDef from a template or a saved collection into an
* EditableField. Used by both modals when hydrating field state.
*
* @param def the source FieldDef
* @param existing true if this is a saved field being edited (freezes the key);
* false if it's being introduced (e.g. from a template) and
* should still have its key auto-synced until touched.
* Templates pass true so their keys aren't overwritten.
*/
export function fieldFromDef(def: FieldDef, existing: boolean): EditableField {
return {
key: def.key,
label: def.label || def.key,
type: def.type,
options: def.options ? [...def.options] : [],
originalOptions: existing && def.options ? [...def.options] : [],
terminalOptions: def.terminal_options ? [...def.terminal_options] : [],
required: def.required,
computed: def.computed,
collection: def.collection,
suffix: def.suffix,
default: def.default,
// Both existing and template fields start with keyTouched=true so
// the key is preserved verbatim, not overwritten by slugify(label).
keyTouched: true
};
}