306 lines
16 KiB
TypeScript
306 lines
16 KiB
TypeScript
import { useMemo, useRef } from "react";
|
|
import type { ApiSettings } from "../../types";
|
|
import { Button } from "@govoplan/core-webui";
|
|
import { PageTitle } from "@govoplan/core-webui";
|
|
import { Card } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
import VersionLine from "./components/VersionLine";
|
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
|
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 "@govoplan/core-webui";
|
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
|
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
|
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
|
const fieldValueKeys = useRef<string[]>([]);
|
|
|
|
const version = data.currentVersion;
|
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
|
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
|
|
settings,
|
|
campaignId,
|
|
version,
|
|
locked,
|
|
reload,
|
|
setError,
|
|
currentStep: "campaign-fields",
|
|
unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
|
|
unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
|
|
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<string, unknown>) {
|
|
patchDraft(["global_values"], nextValues);
|
|
}
|
|
|
|
|
|
function setField(index: number, patchValue: Partial<CampaignFieldDefinition>) {
|
|
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<boolean> {
|
|
const fieldProblem = describeFieldNameProblem(fields);
|
|
if (fieldProblem) {
|
|
setLocalError(fieldProblem);
|
|
setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
|
|
return false;
|
|
}
|
|
return saveDraft("manual");
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="content-pad workspace-data-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
|
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
|
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
|
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
|
|
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
|
<>
|
|
<Card title="i18n:govoplan-campaign.campaign_fields.969e7d80">
|
|
<div className="admin-table-surface campaign-fields-table-surface">
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-fields`}
|
|
rows={fields}
|
|
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
|
getRowKey={(_field, index) => `field-row-${index}`}
|
|
emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
|
|
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
|
|
className="field-editor-table-wrap field-editor-table" />
|
|
|
|
</div>
|
|
</Card>
|
|
</>
|
|
</LoadingFrame>
|
|
</div>);
|
|
|
|
}
|
|
|
|
type FieldColumnContext = {
|
|
locked: boolean;
|
|
fields: CampaignFieldDefinition[];
|
|
globalValues: Record<string, unknown>;
|
|
renameField: (index: number, nextName: string) => void;
|
|
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => 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<CampaignFieldDefinition>[] {
|
|
return [
|
|
{ id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
|
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="i18n:govoplan-campaign.display_label.d747868d" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
|
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
|
|
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
|
{ id: "global_value", header: "i18n:govoplan-campaign.global_value.ab230e7f", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="i18n:govoplan-campaign.optional_default.e04a3bfc" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
|
{ id: "override", header: "i18n:govoplan-campaign.recipient_override.4d84be6d", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can_override", label: "i18n:govoplan-campaign.can_override.c37b61c4" }, { value: "locked", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.can_override.c37b61c4" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" },
|
|
{
|
|
id: "actions",
|
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
|
width: 180,
|
|
sticky: "end",
|
|
render: (_field, index) =>
|
|
<DataGridRowActions
|
|
disabled={locked}
|
|
onAddBelow={() => addField(index)}
|
|
onRemove={() => deleteField(index)}
|
|
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
|
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
|
addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
|
|
removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
|
|
moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
|
|
moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
|
|
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
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<string, unknown>, oldName: string, newName: string): Record<string, unknown> {
|
|
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<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: CampaignFieldDefinition[]): string {
|
|
const names = fields.map((field) => field.name.trim());
|
|
if (names.some((name) => !name)) {
|
|
return "i18n:govoplan-campaign.field_ids_must_not_be_empty_before_saving.3ac7b4ca";
|
|
}
|
|
|
|
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 i18nMessage("i18n:govoplan-campaign.duplicate_field_id_value_value_field_ids_must_be.9343a5e9", { value0: duplicates.size === 1 ? "" : "s", value1: [...duplicates].sort().join(", ") });
|
|
}
|
|
|
|
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;
|
|
}
|