mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
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.
This commit is contained in:
@@ -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<EditableField[]>([]);
|
||||
let newFields = $state<{ key: string; type: FieldDef['type']; options: string }[]>([]);
|
||||
let newFields = $state<EditableField[]>([]);
|
||||
|
||||
// ── 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'}
|
||||
<!-- ── Fields Tab ──────────────────────────────────────── -->
|
||||
<div class="tab-content">
|
||||
{#if existingFields.length > 0}
|
||||
{#if existingFields.length === 0 && newFields.length === 0}
|
||||
<div class="empty-state">No fields defined yet.</div>
|
||||
{:else}
|
||||
<div class="fields-list">
|
||||
{#each existingFields as field, i (field.key)}
|
||||
<div class="field-card">
|
||||
<div class="field-card-header">
|
||||
<div class="field-drag-handle">
|
||||
<button class="reorder-btn" type="button" disabled={i === 0} onclick={() => moveField(i, -1)} title="Move up">▲</button>
|
||||
<button class="reorder-btn" type="button" disabled={i === existingFields.length - 1} onclick={() => moveField(i, 1)} title="Move down">▼</button>
|
||||
</div>
|
||||
<div class="field-header-left">
|
||||
<input class="field-label-input" type="text" bind:value={field.label} placeholder="Field label" />
|
||||
</div>
|
||||
<select class="field-type-select" bind:value={field.type} title="Field type">
|
||||
{#each FIELD_TYPES as ft (ft)}
|
||||
<option value={ft}>{ft.replace('_', ' ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="field-remove-btn" type="button" onclick={() => removeExistingField(i)} title="Remove field">✕</button>
|
||||
</div>
|
||||
|
||||
{#if field.type === 'select' || field.type === 'multi_select'}
|
||||
<div class="field-options">
|
||||
{#if field.key === 'status' && field.options.length > 0}
|
||||
<div class="options-col-headers">
|
||||
<span class="options-col-label">Options</span>
|
||||
<span class="options-col-terminal" title="Terminal statuses are treated as done/closed">Done?</span>
|
||||
<span class="options-col-spacer"></span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="options-rows">
|
||||
{#each field.options as _opt, oi (oi)}
|
||||
<div class="option-row" class:option-terminal={field.key === 'status' && isTerminal(field, field.options[oi])}>
|
||||
<input class="option-name-input" type="text" bind:value={field.options[oi]} placeholder="option name" />
|
||||
{#if field.key === 'status'}
|
||||
<button
|
||||
class="option-done-toggle"
|
||||
class:active={isTerminal(field, field.options[oi])}
|
||||
type="button"
|
||||
onclick={() => toggleTerminal(field, field.options[oi])}
|
||||
title={isTerminal(field, field.options[oi]) ? 'Marked as terminal (click to unmark)' : 'Mark as terminal — items with this status are considered done'}
|
||||
>
|
||||
{#if isTerminal(field, field.options[oi])}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="14" rx="3" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 8L7 10.5L11.5 5.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="1.5" width="13" height="13" rx="2.5" stroke="currentColor" stroke-width="1" opacity="0.4"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="option-remove-btn" type="button" onclick={() => removeOption(field, oi)} title="Remove option">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="option-add-btn" type="button" onclick={() => addOption(field)}>+ Add option</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<FieldEditor
|
||||
bind:field={existingFields[i]}
|
||||
index={i}
|
||||
total={existingFields.length}
|
||||
onmoveup={() => moveField(i, -1)}
|
||||
onmovedown={() => moveField(i, 1)}
|
||||
onremove={() => removeExistingField(i)}
|
||||
/>
|
||||
{/each}
|
||||
{#each newFields as _field, i (i)}
|
||||
<FieldEditor
|
||||
bind:field={newFields[i]}
|
||||
index={i}
|
||||
total={newFields.length}
|
||||
isNew
|
||||
onmoveup={() => moveNewField(i, -1)}
|
||||
onmovedown={() => moveNewField(i, 1)}
|
||||
onremove={() => removeNewField(i)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">No fields defined yet.</div>
|
||||
{/if}
|
||||
|
||||
<div class="add-field-section">
|
||||
{#each newFields as field, i (i)}
|
||||
<div class="new-field-card">
|
||||
<div class="new-field-top">
|
||||
<input class="new-field-name" 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>
|
||||
<button class="field-remove-btn" type="button" onclick={() => removeNewField(i)}>✕</button>
|
||||
</div>
|
||||
{#if field.type === 'select' || field.type === 'multi_select'}
|
||||
<input class="new-field-options" type="text" placeholder="option1, option2, ..." bind:value={field.options} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add-field-btn" type="button" onclick={addField}>+ Add field</button>
|
||||
</div>
|
||||
<button class="add-field-btn" type="button" onclick={addField}>+ Add field</button>
|
||||
</div>
|
||||
{:else if activeTab === 'display'}
|
||||
<!-- ── Display Tab ─────────────────────────────────────── -->
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<script lang="ts">
|
||||
import { FIELD_TYPES, type EditableField } from './field-editor-types';
|
||||
|
||||
interface Props {
|
||||
/** The field being edited. Mutated in place via bindings. */
|
||||
field: EditableField;
|
||||
/** Position in the parent list (0-based). */
|
||||
index: number;
|
||||
/** 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.
|
||||
*/
|
||||
isNew?: boolean;
|
||||
/** 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. */
|
||||
onmovedown?: () => void;
|
||||
/** Remove this field from the parent list. */
|
||||
onremove: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
field = $bindable(),
|
||||
index,
|
||||
total,
|
||||
isNew = false,
|
||||
onmoveup,
|
||||
onmovedown,
|
||||
onremove
|
||||
}: Props = $props();
|
||||
|
||||
const showReorder = $derived(onmoveup !== undefined && onmovedown !== undefined);
|
||||
const isSelectType = $derived(field.type === 'select' || field.type === 'multi_select');
|
||||
|
||||
// Terminal-option toggle is currently scoped to the `status` field only.
|
||||
// This matches the existing EditCollectionModal behavior exactly.
|
||||
// Generalizing to any select field is the job of T4 (TASK-597).
|
||||
const showsTerminalColumn = $derived(field.key === 'status' && field.options.length > 0);
|
||||
|
||||
function removeOption(optIndex: number) {
|
||||
field.options.splice(optIndex, 1);
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
field.options.push('');
|
||||
}
|
||||
|
||||
function toggleTerminal(option: string) {
|
||||
const idx = field.terminalOptions.indexOf(option);
|
||||
if (idx >= 0) {
|
||||
field.terminalOptions.splice(idx, 1);
|
||||
} else {
|
||||
field.terminalOptions.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(option: string): boolean {
|
||||
return field.terminalOptions.includes(option);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field-card">
|
||||
<div class="field-card-header">
|
||||
{#if showReorder}
|
||||
<div class="field-drag-handle">
|
||||
<button
|
||||
class="reorder-btn"
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onclick={onmoveup}
|
||||
title="Move up"
|
||||
aria-label="Move field up"
|
||||
>▲</button>
|
||||
<button
|
||||
class="reorder-btn"
|
||||
type="button"
|
||||
disabled={index === total - 1}
|
||||
onclick={onmovedown}
|
||||
title="Move down"
|
||||
aria-label="Move field down"
|
||||
>▼</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="field-header-left">
|
||||
<input
|
||||
class="field-label-input"
|
||||
type="text"
|
||||
bind:value={field.label}
|
||||
placeholder={isNew ? 'Field name' : 'Field label'}
|
||||
/>
|
||||
</div>
|
||||
<select class="field-type-select" bind:value={field.type} title="Field type">
|
||||
{#each FIELD_TYPES as ft (ft)}
|
||||
<option value={ft}>{ft.replace('_', ' ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="field-remove-btn"
|
||||
type="button"
|
||||
onclick={onremove}
|
||||
title="Remove field"
|
||||
aria-label="Remove field"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{#if isSelectType}
|
||||
<div class="field-options">
|
||||
{#if showsTerminalColumn}
|
||||
<div class="options-col-headers">
|
||||
<span class="options-col-label">Options</span>
|
||||
<span
|
||||
class="options-col-terminal"
|
||||
title="Terminal statuses are treated as done/closed"
|
||||
>Done?</span>
|
||||
<span class="options-col-spacer"></span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="options-rows">
|
||||
{#each field.options as _opt, oi (oi)}
|
||||
<div
|
||||
class="option-row"
|
||||
class:option-terminal={field.key === 'status' &&
|
||||
isTerminal(field.options[oi])}
|
||||
>
|
||||
<input
|
||||
class="option-name-input"
|
||||
type="text"
|
||||
bind:value={field.options[oi]}
|
||||
placeholder="option name"
|
||||
/>
|
||||
{#if field.key === 'status'}
|
||||
<button
|
||||
class="option-done-toggle"
|
||||
class:active={isTerminal(field.options[oi])}
|
||||
type="button"
|
||||
onclick={() => toggleTerminal(field.options[oi])}
|
||||
title={isTerminal(field.options[oi])
|
||||
? 'Marked as terminal (click to unmark)'
|
||||
: 'Mark as terminal — items with this status are considered done'}
|
||||
aria-label={isTerminal(field.options[oi])
|
||||
? 'Unmark as terminal'
|
||||
: 'Mark as terminal'}
|
||||
>
|
||||
{#if isTerminal(field.options[oi])}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="3"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M4.5 8L7 10.5L11.5 5.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect
|
||||
x="1.5"
|
||||
y="1.5"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="option-remove-btn"
|
||||
type="button"
|
||||
onclick={() => removeOption(oi)}
|
||||
title="Remove option"
|
||||
aria-label="Remove option"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="option-add-btn" type="button" onclick={addOption}>+ Add option</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
|
||||
.reorder-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6em;
|
||||
cursor: pointer;
|
||||
padding: 2px var(--space-1);
|
||||
line-height: 1.2;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.reorder-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.reorder-btn:disabled {
|
||||
opacity: 0.25;
|
||||
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-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);
|
||||
}
|
||||
</style>
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user