Files
CapaKraken/apps/web/src/components/blueprints/BlueprintFieldEditor.tsx
T
Hartmut 05aa864359 refactor(ui): replace inline INPUT_CLS/LABEL_CLS/BTN_DANGER constants and action link classes with CSS component classes
Remove duplicated Tailwind class string constants from 15 component files.
Use app-input, app-select, app-label, app-action-danger-btn, and
app-action-delete CSS component classes from globals.css instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:21:03 +02:00

497 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: FieldType.TEXT, label: "Text" },
{ value: FieldType.TEXTAREA, label: "Textarea" },
{ value: FieldType.NUMBER, label: "Number" },
{ value: FieldType.BOOLEAN, label: "Boolean" },
{ value: FieldType.DATE, label: "Date" },
{ value: FieldType.SELECT, label: "Select" },
{ value: FieldType.MULTI_SELECT, label: "Multi-Select" },
{ value: FieldType.URL, label: "URL" },
{ value: FieldType.EMAIL, label: "Email" },
];
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
function makeEmptyField(order: number): BlueprintFieldDefinition {
return {
id: Math.random().toString(36).slice(2),
key: "",
label: "",
type: FieldType.TEXT,
required: false,
order,
};
}
// ---------------------------------------------------------------------------
// OptionsEditor — for SELECT / MULTI_SELECT
// ---------------------------------------------------------------------------
interface OptionsEditorProps {
options: FieldOption[];
onChange: (options: FieldOption[]) => void;
}
function OptionsEditor({ options, onChange }: OptionsEditorProps) {
function addOption() {
onChange([...options, { value: "", label: "" }]);
}
function removeOption(idx: number) {
onChange(options.filter((_, i) => i !== idx));
}
function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) =>
i === idx ? { ...o, [field]: val } : o,
);
onChange(next);
}
return (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-600">Options</p>
{options.map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={opt.value}
onChange={(e) => updateOption(idx, "value", e.target.value)}
placeholder="value"
className="app-input flex-1"
/>
<input
type="text"
value={opt.label}
onChange={(e) => updateOption(idx, "label", e.target.value)}
placeholder="label"
className="app-input flex-1"
/>
<button
type="button"
onClick={() => removeOption(idx)}
className="app-action-danger-btn"
aria-label="Remove option"
>
×
</button>
</div>
))}
<button
type="button"
onClick={addOption}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
+ Add option
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// FieldRow — a single field definition row
// ---------------------------------------------------------------------------
interface FieldRowProps {
field: BlueprintFieldDefinition;
onChange: (field: BlueprintFieldDefinition) => void;
onDelete: () => void;
}
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false);
const needsOptions =
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>(
key: K,
value: BlueprintFieldDefinition[K],
) {
onChange({ ...field, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
</span>
{/* Key */}
<input
type="text"
value={field.key}
onChange={(e) => update("key", e.target.value)}
placeholder="field_key"
className="app-input w-36 font-mono"
aria-label="Field key"
/>
{/* Label */}
<input
type="text"
value={field.label}
onChange={(e) => update("label", e.target.value)}
placeholder="Label"
className="app-input w-40"
aria-label="Field label"
/>
{/* Type */}
<select
value={field.type}
onChange={(e) => {
const t = e.target.value as FieldType;
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
className="app-input w-36"
aria-label="Field type"
>
{FIELD_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>
{ft.label}
</option>
))}
</select>
{/* Required */}
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none whitespace-nowrap">
<input
type="checkbox"
checked={field.required}
onChange={(e) => update("required", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Req.
</label>
{/* Expand/Collapse optional fields */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-xs text-gray-400 hover:text-gray-600 ml-auto whitespace-nowrap"
aria-label={expanded ? "Collapse options" : "Expand options"}
>
{expanded ? "▲ less" : "▼ more"}
</button>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className="app-action-danger-btn"
aria-label="Delete field"
>
×
</button>
</div>
{/* Expanded optional fields */}
{expanded && (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">Group</label>
<input
type="text"
value={field.group ?? ""}
onChange={(e) => update("group", e.target.value || undefined)}
placeholder="Section heading"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Placeholder
</label>
<input
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
placeholder="Placeholder text"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Description
</label>
<input
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
placeholder="Helper text"
className="app-input"
/>
</div>
<div className="flex items-center gap-4 col-span-full pt-1">
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.showInList ?? false}
onChange={(e) => update("showInList", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Show in list view
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.isFilterable ?? false}
onChange={(e) => update("isFilterable", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Filterable
</label>
</div>
{needsOptions && (
<div className="col-span-full">
<OptionsEditor
options={field.options ?? []}
onChange={(opts) => update("options", opts)}
/>
</div>
)}
</div>
)}
{/* Options inline hint when collapsed */}
{!expanded && needsOptions && (field.options?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-amber-600">
No options defined yet click more to add them.
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// BlueprintFieldEditor — the modal
// ---------------------------------------------------------------------------
interface BlueprintFieldEditorProps {
blueprintId: string;
blueprintName: string;
initialFieldDefs: BlueprintFieldDefinition[];
initialRolePresets?: StaffingRequirement[];
initialTab?: "fields" | "presets";
onClose: () => void;
}
export function BlueprintFieldEditor({
blueprintId,
blueprintName,
initialFieldDefs,
initialRolePresets = [],
initialTab = "fields",
onClose,
}: BlueprintFieldEditorProps) {
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
const updateMutation = trpc.blueprint.update.useMutation();
const presetMutation = trpc.blueprint.updateRolePresets.useMutation();
function addField() {
setFields((prev) => [...prev, makeEmptyField(prev.length)]);
}
function removeField(idx: number) {
setFields((prev) =>
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
}
function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
}
function handleSave() {
setSaveError(null);
// Reassign order by current list position
const normalised = fields.map((f, i) => ({ ...f, order: i }));
updateMutation.mutate(
{
id: blueprintId,
data: { fieldDefs: normalised },
},
{
onSuccess: async () => {
await utils.blueprint.list.invalidate();
onClose();
},
onError: (err) => {
setSaveError(err.message);
},
},
);
}
// Close on backdrop click
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 px-6">
{(["fields", "presets"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab === "fields" ? "Fields" : "Role Presets"}
</button>
))}
</div>
{activeTab === "fields" ? (
<>
{/* Field list */}
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fields.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No fields yet. Click &ldquo;+ Add Field&rdquo; to get started.
</p>
)}
{fields.map((field, idx) => (
<FieldRow
key={field.id}
field={field}
onChange={(updated) => updateField(idx, updated)}
onDelete={() => removeField(idx)}
/>
))}
</div>
{/* Add field button */}
<div className="px-6 pb-2">
<button
type="button"
onClick={addField}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Field
</button>
</div>
{/* Error */}
{saveError && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{saveError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className={BTN_PRIMARY}
>
{updateMutation.isPending ? "Saving…" : "Save Fields"}
</button>
</div>
</>
) : (
<div className="px-6 py-4">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
onSave={(presets) =>
presetMutation.mutate(
{ id: blueprintId, rolePresets: presets },
{
onSuccess: async () => {
await utils.blueprint.list.invalidate();
setPresetSaveError(null);
onClose();
},
onError: (err) => {
setPresetSaveError(err.message);
},
},
)
}
isSaving={presetMutation.isPending}
saveError={presetSaveError}
/>
<div className="flex justify-start mt-4 border-t border-gray-200 pt-4">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Close
</button>
</div>
</div>
)}
</div>
</div>
);
}