From ecea75dcd802b2e40b712d6952a81aab1dcccb46 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 17 Apr 2026 12:38:49 -0400 Subject: [PATCH] refactor(web): extract shared FieldEditor component (#133) Pull the field-card UI out of EditCollectionModal into a reusable FieldEditor.svelte so existing and new fields render identically. Foundation for PLAN-593 (Collection Modal Redesign, TASK-594). - Add FieldEditor.svelte with its own scoped styles for the field card, reorder buttons, select/multi_select options editor, and status-field terminal toggle. - Add field-editor-types.ts with the shared EditableField interface, FIELD_TYPES list, and a blankField() factory. - EditCollectionModal adopts FieldEditor for both existing and new (unsaved) fields, replacing the stripped-down comma-separated row form. - Merge the two field lists into one visual container so new fields flow continuously with existing ones; drop the horizontal divider. - New fields gain reorder buttons within the new-fields list as a natural consequence of the unification. - Save logic, migration building, and status-terminal behavior are preserved exactly. Key-from-label derivation for new fields now reads from 'label' (was 'key'), maintaining identical user-visible behavior until TASK-595 introduces slugification. --- .../collections/EditCollectionModal.svelte | 481 ++---------------- .../components/collections/FieldEditor.svelte | 447 ++++++++++++++++ .../collections/field-editor-types.ts | 52 ++ 3 files changed, 550 insertions(+), 430 deletions(-) create mode 100644 web/src/lib/components/collections/FieldEditor.svelte create mode 100644 web/src/lib/components/collections/field-editor-types.ts diff --git a/web/src/lib/components/collections/EditCollectionModal.svelte b/web/src/lib/components/collections/EditCollectionModal.svelte index 8fe864cb..d62f5247 100644 --- a/web/src/lib/components/collections/EditCollectionModal.svelte +++ b/web/src/lib/components/collections/EditCollectionModal.svelte @@ -4,6 +4,8 @@ import { parseSchema, parseSettings } from '$lib/types'; 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 { toastStore } from '$lib/stores/toast.svelte'; interface Props { @@ -38,17 +40,6 @@ } } - const FIELD_TYPES: FieldDef['type'][] = [ - 'text', - 'number', - 'select', - 'multi_select', - 'date', - 'checkbox', - 'url', - 'relation' - ]; - // ── Tab state ──────────────────────────────────────────────────────────── let activeTab = $state<'general' | 'fields' | 'display' | 'actions'>('general'); @@ -62,23 +53,11 @@ let error = $state(''); // ── Field editing state ────────────────────────────────────────────────── - - interface EditableField { - key: string; - label: string; - type: FieldDef['type']; - options: string[]; - originalOptions: string[]; - terminalOptions: string[]; - required?: boolean; - computed?: boolean; - collection?: string; - suffix?: string; - default?: any; - } + // EditableField shape lives in `./field-editor-types.ts` so FieldEditor + // and this modal share one type. let existingFields = $state([]); - let newFields = $state<{ key: string; type: FieldDef['type']; options: string }[]>([]); + let newFields = $state([]); // ── Display settings state ────────────────────────────────────────────── @@ -200,39 +179,24 @@ existingFields.splice(index, 1); } - // ── Option editing for select fields ───────────────────────────────────── - - function removeOption(field: EditableField, optIndex: number) { - field.options.splice(optIndex, 1); - } - - function addOption(field: EditableField) { - field.options.push(''); - } - - function toggleTerminal(field: EditableField, option: string) { - const idx = field.terminalOptions.indexOf(option); - if (idx >= 0) { - field.terminalOptions.splice(idx, 1); - } else { - field.terminalOptions.push(option); - } - } - - function isTerminal(field: EditableField, option: string): boolean { - return field.terminalOptions.includes(option); - } - // ── New field actions ──────────────────────────────────────────────────── function addField() { - newFields.push({ key: '', type: 'text', options: '' }); + newFields.push(blankField()); } function removeNewField(index: number) { newFields.splice(index, 1); } + function moveNewField(index: number, direction: -1 | 1) { + const target = index + direction; + if (target < 0 || target >= newFields.length) return; + const temp = newFields[index]; + newFields[index] = newFields[target]; + newFields[target] = temp; + } + // ── Build migrations ───────────────────────────────────────────────────── function buildMigrations(): FieldMigration[] { @@ -289,15 +253,18 @@ }); // 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. const addedFields: FieldDef[] = newFields - .filter((f) => f.key.trim()) + .filter((f) => f.label.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 name = f.label.trim(); + const def: FieldDef = { key: name, label: name, 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; } return def; }); @@ -451,87 +418,35 @@ {:else if activeTab === 'fields'}
- {#if existingFields.length > 0} + {#if existingFields.length === 0 && newFields.length === 0} +
No fields defined yet.
+ {:else}
{#each existingFields as field, i (field.key)} -
-
-
- - -
-
- -
- - -
- - {#if field.type === 'select' || field.type === 'multi_select'} -
- {#if field.key === 'status' && field.options.length > 0} -
- Options - Done? - -
- {/if} -
- {#each field.options as _opt, oi (oi)} -
- - {#if field.key === 'status'} - - {/if} - -
- {/each} -
- -
- {/if} -
+ moveField(i, -1)} + onmovedown={() => moveField(i, 1)} + onremove={() => removeExistingField(i)} + /> + {/each} + {#each newFields as _field, i (i)} + moveNewField(i, -1)} + onmovedown={() => moveNewField(i, 1)} + onremove={() => removeNewField(i)} + /> {/each}
- {:else} -
No fields defined yet.
{/if} -
- {#each newFields as field, i (i)} -
-
- - - -
- {#if field.type === 'select' || field.type === 'multi_select'} - - {/if} -
- {/each} - -
+
{:else if activeTab === 'display'} @@ -925,27 +840,8 @@ gap: var(--space-3); } - .field-card { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - } - - .field-card-header { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-3); - } - - .field-drag-handle { - display: flex; - flex-direction: column; - gap: 1px; - flex-shrink: 0; - } - + /* Shared reorder button — used by Quick Actions rows. The Fields tab + gets its reorder-btn styles from FieldEditor.svelte. */ .reorder-btn { background: none; border: none; @@ -967,219 +863,6 @@ cursor: default; } - .field-header-left { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .field-label-input { - width: 100%; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 0.9em; - font-weight: 500; - color: var(--text-primary); - } - - .field-label-input:hover { - border-color: var(--border); - } - - .field-label-input:focus { - border-color: var(--accent-blue); - outline: none; - } - - .field-key { - font-size: 0.72em; - color: var(--text-muted); - padding-left: var(--space-2); - font-family: var(--font-mono); - } - - .field-type-select { - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 0.82em; - color: var(--text-primary); - cursor: pointer; - flex-shrink: 0; - } - - .field-type-select:hover { - border-color: var(--border); - } - - .field-type-select:focus { - border-color: var(--accent-blue); - outline: none; - } - - .field-remove-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.82em; - cursor: pointer; - padding: var(--space-1); - border-radius: var(--radius-sm); - line-height: 1; - flex-shrink: 0; - } - - .field-remove-btn:hover { - color: var(--accent-red, #ef4444); - background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent); - } - - /* ── Options area (select / multi_select) ──────────────────────────────── */ - - .field-options { - border-top: 1px solid var(--border); - padding: var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + 28px); - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .options-col-headers { - display: flex; - align-items: center; - gap: var(--space-2); - padding-bottom: var(--space-1); - } - - .options-col-label { - flex: 1; - font-size: 0.72em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - } - - .options-col-terminal { - width: 40px; - text-align: center; - font-size: 0.72em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - } - - .options-col-spacer { - width: 22px; - flex-shrink: 0; - } - - .options-rows { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - .option-row { - display: flex; - align-items: center; - gap: var(--space-2); - } - - .option-row.option-terminal .option-name-input { - border-color: color-mix(in srgb, var(--accent-green, #22c55e) 30%, var(--border)); - background: color-mix(in srgb, var(--accent-green, #22c55e) 4%, var(--bg-secondary)); - } - - .option-name-input { - flex: 1; - min-width: 0; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 0.85em; - color: var(--text-primary); - } - - .option-name-input:hover { - border-color: var(--border); - } - - .option-name-input:focus { - border-color: var(--accent-blue); - outline: none; - } - - .option-done-toggle { - width: 40px; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: var(--space-1); - border-radius: var(--radius-sm); - flex-shrink: 0; - transition: color 0.15s; - } - - .option-done-toggle:hover { - color: var(--text-secondary); - } - - .option-done-toggle.active { - color: var(--accent-green, #22c55e); - } - - .option-remove-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.75em; - cursor: pointer; - padding: var(--space-1); - border-radius: var(--radius-sm); - line-height: 1; - flex-shrink: 0; - opacity: 0; - transition: opacity 0.1s; - } - - .option-row:hover .option-remove-btn { - opacity: 1; - } - - .option-remove-btn:hover { - color: var(--accent-red, #ef4444); - background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent); - } - - .option-add-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.82em; - cursor: pointer; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - text-align: left; - width: fit-content; - } - - .option-add-btn:hover { - color: var(--accent-blue); - background: color-mix(in srgb, var(--accent-blue) 6%, transparent); - } - .empty-state { padding: var(--space-6) var(--space-4); text-align: center; @@ -1187,17 +870,10 @@ font-size: 0.88em; } - /* ── Add field section ─────────────────────────────────────────────────── */ - - .add-field-section { - display: flex; - flex-direction: column; - gap: var(--space-3); - border-top: 1px solid var(--border); - padding-top: var(--space-4); - } + /* ── Add field button ──────────────────────────────────────────────────── */ .add-field-btn { + margin-top: var(--space-3); background: none; border: 1px dashed var(--border); color: var(--accent-blue); @@ -1215,61 +891,6 @@ border-color: var(--accent-blue); } - .new-field-card { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: var(--space-3); - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .new-field-top { - display: flex; - align-items: center; - gap: var(--space-2); - } - - .new-field-name { - flex: 1; - min-width: 120px; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 0.85em; - color: var(--text-primary); - } - - .new-field-name:hover { - border-color: var(--border); - } - - .new-field-name:focus { - border-color: var(--accent-blue); - outline: none; - } - - .new-field-options { - width: 100%; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 0.85em; - color: var(--text-primary); - } - - .new-field-options:hover { - border-color: var(--border); - } - - .new-field-options:focus { - border-color: var(--accent-blue); - outline: none; - } - /* ── Display tab ───────────────────────────────────────────────────────── */ .settings-grid { diff --git a/web/src/lib/components/collections/FieldEditor.svelte b/web/src/lib/components/collections/FieldEditor.svelte new file mode 100644 index 00000000..065d2452 --- /dev/null +++ b/web/src/lib/components/collections/FieldEditor.svelte @@ -0,0 +1,447 @@ + + +
+
+ {#if showReorder} +
+ + +
+ {/if} +
+ +
+ + +
+ + {#if isSelectType} +
+ {#if showsTerminalColumn} +
+ Options + Done? + +
+ {/if} +
+ {#each field.options as _opt, oi (oi)} +
+ + {#if field.key === 'status'} + + {/if} + +
+ {/each} +
+ +
+ {/if} +
+ + diff --git a/web/src/lib/components/collections/field-editor-types.ts b/web/src/lib/components/collections/field-editor-types.ts new file mode 100644 index 00000000..8403dba1 --- /dev/null +++ b/web/src/lib/components/collections/field-editor-types.ts @@ -0,0 +1,52 @@ +import type { FieldDef } from '$lib/types'; + +/** + * Editable view of a FieldDef used by the collection modals. + * + * Mirrors FieldDef but: + * - `options` is always an array (never undefined) for easier binding. + * - `originalOptions` snapshots the original option order/values so we can + * build rename migrations on save. + * - `terminalOptions` is always an array for easier binding (mirrors + * FieldDef.terminal_options). + * + * 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`. + */ +export interface EditableField { + key: string; + label: string; + type: FieldDef['type']; + options: string[]; + originalOptions: string[]; + terminalOptions: string[]; + required?: boolean; + computed?: boolean; + collection?: string; + suffix?: string; + default?: unknown; +} + +export const FIELD_TYPES: FieldDef['type'][] = [ + 'text', + 'number', + 'select', + 'multi_select', + 'date', + 'checkbox', + 'url', + 'relation' +]; + +/** Create an empty EditableField for a new (unsaved) field. */ +export function blankField(): EditableField { + return { + key: '', + label: '', + type: 'text', + options: [], + originalOptions: [], + terminalOptions: [] + }; +}