mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
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:
@@ -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)}
|
||||
>
|
||||
✕
|
||||
</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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user