first wokring prototype
This commit is contained in:
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal file
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user