first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View File

@@ -0,0 +1,308 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
type FieldDefinition = {
name: string;
label: string;
type: string;
required: boolean;
can_override: boolean;
};
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const fields = useMemo(() => normalizeFields(draft?.fields), [draft?.fields]);
const globalValues = asRecord(draft?.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
useEffect(() => {
if (!version) return;
const nextDraft = migrateFieldOverridePolicy(ensureCampaignDraft(version), asRecord(version.editor_state));
fieldValueKeys.current = normalizeFields(nextDraft.fields).map((field) => field.name);
setDraft(nextDraft);
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchFields(nextFields: FieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
label: field.label,
required: field.required,
can_override: field.can_override
})));
}
function patchGlobalValues(nextValues: Record<string, unknown>) {
patchDraft(["global_values"], nextValues);
}
function setField(index: number, patchValue: Partial<FieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
function renameField(index: number, nextName: string) {
const oldName = fields[index]?.name ?? "";
const cleanedName = nextName.trim();
const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName);
const valueKey = fieldValueKeys.current[index] ?? oldName;
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field);
const nextGlobalValues = { ...globalValues };
if (!duplicate && cleanedName) {
if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) {
nextGlobalValues[cleanedName] = nextGlobalValues[valueKey];
delete nextGlobalValues[valueKey];
}
fieldValueKeys.current[index] = cleanedName;
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function addField() {
const name = uniqueFieldName(fields);
const nextFields = [...fields, { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }];
fieldValueKeys.current = [...fieldValueKeys.current, name];
const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function deleteField(index: number) {
const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index);
const nextGlobalValues = { ...globalValues };
if (field?.name) {
delete nextGlobalValues[field.name];
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function setGlobalValue(key: string, value: string) {
patchGlobalValues({ ...globalValues, [key]: value });
}
function setOverrideAllowed(index: number, allowed: boolean) {
setField(index, { can_override: allowed });
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-fields",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved fields",
message: "Fields have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
{draft && (
<>
<Card
title="Fields and global values"
actions={<Button variant="primary" onClick={addField} disabled={locked}>Add field</Button>}
>
<div className="app-table-wrap field-editor-table-wrap">
<table className="app-table field-editor-table">
<thead>
<tr>
<th>Field ID</th>
<th>Label</th>
<th>Type</th>
<th>Required</th>
<th>Global value</th>
<th>Recipient override</th>
<th></th>
</tr>
</thead>
<tbody>
{fields.length === 0 ? (
<tr>
<td colSpan={7} className="empty-table-cell">No campaign fields configured yet.</td>
</tr>
) : fields.map((field, index) => (
<tr key={`field-row-${index}`}>
<td><input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} /></td>
<td><input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></td>
<td>
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: event.target.value })}>
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</td>
<td><ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} /></td>
<td><input value={valueToText(globalValues[field.name])} disabled={locked || !field.name} placeholder="Optional default" onChange={(event) => setGlobalValue(field.name, event.target.value)} /></td>
<td><ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} /></td>
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Delete</Button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
)}
</div>
);
}
function normalizeFields(value: unknown): FieldDefinition[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((field) => ({
name: getText(field, "name"),
label: getText(field, "label"),
type: fieldTypeOptions.includes(getText(field, "type")) ? getText(field, "type") : "string",
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
}
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
const overridePolicy = asRecord(editorState.field_overrides);
if (Object.keys(overridePolicy).length === 0) return draft;
const fields = normalizeFields(draft.fields).map((field) => {
if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field;
return { ...field, can_override: getBool(overridePolicy, field.name, true) };
});
return updateNested(draft, ["fields"], fields);
}
function describeFieldNameProblem(fields: FieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
}
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: FieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
while (existing.has(name)) {
counter += 1;
name = `field_${counter}`;
}
return name;
}
function humanizeFieldName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function valueToText(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}