import { useMemo, useRef } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import ToggleSwitch from "../../components/ToggleSwitch"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView"; import { getBool, getText, updateNested } from "./utils/draftEditor"; import FieldValueInput from "./components/FieldValueInput"; import DismissibleAlert from "../../components/DismissibleAlert"; import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const fieldValueKeys = useRef([]); const version = data.currentVersion; const locked = isAuditLockedVersion(version); const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "campaign-fields", unsavedTitle: "Unsaved fields", unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.", transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)), onLoaded: (_loadedVersion, loadedDraft) => { fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name); } }); const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]); const globalValues = asRecord(displayDraft.global_values); const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]); const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning; function patchDraft(path: string[], value: unknown) { if (locked) return; setDraft((current) => updateNested(current ?? {}, path, value)); markDirty(); } function patchFields(nextFields: CampaignFieldDefinition[]) { 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) { patchDraft(["global_values"], nextValues); } function setField(index: number, patchValue: Partial) { 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); const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues); if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) { return nextWithGlobalValues; } return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName); }); markDirty(); } function addField(afterIndex = fields.length - 1) { const name = uniqueFieldName(fields); const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }; const nextFields = insertAfter(fields, afterIndex, nextField); fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name); const nextGlobalValues = { ...globalValues, [name]: "" }; setDraft((current) => { const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); return updateNested(nextDraft, ["global_values"], nextGlobalValues); }); markDirty(); } function moveField(index: number, targetIndex: number) { if (locked || index === targetIndex) return; fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex); patchFields(moveArrayItem(fields, index, targetIndex)); } 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); }); markDirty(); } function setGlobalValue(key: string, value: unknown) { patchGlobalValues({ ...globalValues, [key]: value }); } function setOverrideAllowed(index: number, allowed: boolean) { setField(index, { can_override: allowed }); } async function saveFields(): Promise { const fieldProblem = describeFieldNameProblem(fields); if (fieldProblem) { setLocalError(fieldProblem); setSaveState("Save blocked"); return false; } return saveDraft("manual"); } return (
Fields
{error && {error}} {localError && {localError}} {fieldNameWarning && {fieldNameWarning}} {locked && } <> `field-row-${index}`} emptyText={
No campaign fields configured yet.
} className="field-editor-table-wrap field-editor-table" />
); } type FieldColumnContext = { locked: boolean; fields: CampaignFieldDefinition[]; globalValues: Record; renameField: (index: number, nextName: string) => void; setField: (index: number, patchValue: Partial) => void; setGlobalValue: (key: string, value: unknown) => void; setOverrideAllowed: (index: number, allowed: boolean) => void; addField: (afterIndex?: number) => void; moveField: (index: number, targetIndex: number) => void; deleteField: (index: number) => void; }; function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn[] { return [ { id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => renameField(index, event.target.value)} />, value: (field) => field.name }, { id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => setField(index, { label: event.target.value })} />, value: (field) => field.label }, { id: "type", header: "Type", width: 140, sortable: true, filterable: true, render: (field, index) => , value: (field) => field.type }, { id: "required", header: "Required", width: 140, sortable: true, filterable: true, render: (field, index) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" }, { id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") }, { id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, render: (field, index) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" }, { id: "actions", header: "Actions", width: 150, sticky: "end", render: (_field, index) => ( addField(index)} onRemove={() => deleteField(index)} onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined} onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined} addLabel="Add field below" removeLabel="Remove field" moveUpLabel="Move field up" moveDownLabel="Move field down" /> ) } ]; } function normalizeFields(value: unknown): CampaignFieldDefinition[] { if (!Array.isArray(value)) return []; return value.filter(isRecord).map((field) => ({ name: getText(field, "name"), label: getText(field, "label"), type: normalizeFieldType(getText(field, "type")), required: getBool(field, "required"), can_override: getBool(field, "can_override", true) })); } function migrateEntryFieldValues(draft: Record, oldName: string, newName: string): Record { const entries = asRecord(draft.entries); const inlineEntries = Array.isArray(entries.inline) ? entries.inline : []; if (inlineEntries.length === 0) return draft; const nextInlineEntries = inlineEntries.map((entry) => { if (!isRecord(entry)) return entry; const fields = asRecord(entry.fields); if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry; const nextFields = { ...fields }; if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) { nextFields[newName] = nextFields[oldName]; } delete nextFields[oldName]; return { ...entry, fields: nextFields }; }); return updateNested(draft, ["entries", "inline"], nextInlineEntries); } function migrateFieldOverridePolicy(draft: Record, editorState: Record): Record { 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: CampaignFieldDefinition[]): 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(); const duplicates = new Set(); 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: CampaignFieldDefinition[]): 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; }