Files
govoplan-campaign/webui/src/features/campaigns/components/PostboxTargetsDialog.tsx
T

568 lines
18 KiB
TypeScript

import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type {
CampaignPostboxCatalog,
CampaignPostboxOrganizationUnit
} from "../../../api/campaigns";
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
export type CampaignPostboxTarget = {
id: string;
mode: "direct" | "derived";
label?: string;
postbox_id?: string;
address_key?: string;
template_id?: string;
organization_unit_id?: string;
organization_unit_field?: string;
organization_unit_match?: "id" | "slug";
function_id?: string;
function_field?: string;
function_match?: "id" | "slug";
context_key?: string;
context_field?: string;
};
type Props = {
open: boolean;
title: string;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
targets: CampaignPostboxTarget[];
locked?: boolean;
merge?: boolean;
showMerge?: boolean;
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
onClose: () => void;
};
export default function PostboxTargetsDialog({
open,
title,
catalog,
fields,
targets,
locked = false,
merge = true,
showMerge = false,
onSave,
onClose
}: Props) {
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
() => targets.map(normalizeTarget)
);
const [draftMerge, setDraftMerge] = useState(merge);
const [error, setError] = useState("");
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
setDraftTargets((current) =>
current.map((target, currentIndex) =>
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
)
);
setError("");
}
function addTarget() {
const firstPostbox = catalog.postboxes[0];
setDraftTargets((current) => [
...current,
{
id: newTargetId(),
mode: "direct",
postbox_id: firstPostbox?.id ?? ""
}
]);
}
function save() {
const prepared = draftTargets.map(normalizeTarget);
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
if (invalidIndex >= 0) {
setError(`Target ${invalidIndex + 1} is incomplete.`);
return;
}
onSave(prepared, draftMerge);
}
return (
<Dialog
open={open}
title={title}
className="campaign-postbox-target-modal"
bodyClassName="campaign-postbox-target-body"
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
</>
}
>
<div className="campaign-postbox-target-toolbar">
{showMerge &&
<ToggleSwitch
label="Postbox target defaults"
inactiveLabel="Replace defaults"
activeLabel="Add to defaults"
checked={draftMerge}
disabled={locked}
onChange={setDraftMerge}
/>
}
<Button
type="button"
variant="primary"
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
onClick={addTarget}
>
<Plus aria-hidden="true" />
Add target
</Button>
</div>
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
{draftTargets.length === 0 &&
<div className="data-grid-empty">No Postbox targets configured.</div>
}
<div className="campaign-postbox-target-list">
{draftTargets.map((target, index) =>
<PostboxTargetRow
key={target.id}
target={target}
index={index}
count={draftTargets.length}
catalog={catalog}
fields={fields}
locked={locked}
onChange={(patch) => updateTarget(index, patch)}
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
/>
)}
</div>
</Dialog>
);
}
function PostboxTargetRow({
target,
index,
count,
catalog,
fields,
locked,
onChange,
onMove,
onRemove
}: {
target: CampaignPostboxTarget;
index: number;
count: number;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
const selectedUnit = catalog.organization_units.find(
(unit) => unit.id === target.organization_unit_id
);
return (
<section className="campaign-postbox-target-row">
<div className="campaign-postbox-target-row-heading">
<strong>Target {index + 1}</strong>
<TableActionGroup actions={[
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
]} />
</div>
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
<FormField label="Target type">
<select
value={target.mode}
disabled={locked}
onChange={(event) => onChange(
event.target.value === "derived"
? derivedTargetDefaults(target.id, catalog, fields)
: directTargetDefaults(target.id, catalog)
)}
>
<option value="direct">Direct Postbox</option>
<option value="derived">Derived from fields</option>
</select>
</FormField>
<FormField label="Label">
<input
value={target.label ?? ""}
disabled={locked}
onChange={(event) => onChange({ label: event.target.value })}
/>
</FormField>
{target.mode === "direct" ?
<DirectTargetFields
target={target}
catalog={catalog}
locked={locked}
onChange={onChange}
/> :
<DerivedTargetFields
target={target}
catalog={catalog}
fields={fields}
selectedUnit={selectedUnit}
locked={locked}
onChange={onChange}
/>
}
</div>
</section>
);
}
function DirectTargetFields({
target,
catalog,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
return (
<FormField label="Postbox">
<select
value={target.postbox_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ postbox_id: event.target.value })}
>
<option value="">Select a Postbox</option>
{catalog.postboxes.map((postbox) =>
<option key={postbox.id} value={postbox.id}>
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
</option>
)}
</select>
</FormField>
);
}
function DerivedTargetFields({
target,
catalog,
fields,
selectedUnit,
locked,
onChange
}: {
target: CampaignPostboxTarget;
catalog: CampaignPostboxCatalog;
fields: CampaignFieldDefinition[];
selectedUnit?: CampaignPostboxOrganizationUnit;
locked: boolean;
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
}) {
const organizationFields = useMemo(
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
[fields]
);
const functionFields = useMemo(
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
[fields]
);
return (
<>
<FormField label="Postbox template">
<select
value={target.template_id ?? ""}
disabled={locked}
onChange={(event) => onChange({ template_id: event.target.value })}
>
<option value="">Select a template</option>
{catalog.templates.map((template) =>
<option key={template.id} value={template.id}>{template.name}</option>
)}
</select>
</FormField>
<DerivedReferenceField
label="Organization unit"
fixedValue={target.organization_unit_id}
fieldValue={target.organization_unit_field}
match={target.organization_unit_match}
fixedOptions={catalog.organization_units}
fields={organizationFields}
locked={locked}
onFixed={(value) => onChange({
organization_unit_id: value,
organization_unit_field: undefined,
function_id: undefined
})}
onField={(value) => onChange({
organization_unit_id: undefined,
organization_unit_field: value
})}
onMatch={(value) => onChange({ organization_unit_match: value })}
/>
<DerivedReferenceField
label="Function"
fixedValue={target.function_id}
fieldValue={target.function_field}
match={target.function_match}
fixedOptions={selectedUnit?.functions ?? []}
fields={functionFields}
locked={locked}
fixedDisabled={!target.organization_unit_id}
onFixed={(value) => onChange({
function_id: value,
function_field: undefined
})}
onField={(value) => onChange({
function_id: undefined,
function_field: value
})}
onMatch={(value) => onChange({ function_match: value })}
/>
<FormField label="Context source">
<select
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
disabled={locked}
onChange={(event) => {
const mode = event.target.value;
onChange({
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
});
}}
>
<option value="none">No context</option>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{target.context_field !== undefined ?
<FormField label="Context field">
<select
value={target.context_field}
disabled={locked}
onChange={(event) => onChange({ context_field: event.target.value })}
>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField> :
target.context_key !== undefined &&
<FormField label="Context value">
<input
value={target.context_key}
disabled={locked}
onChange={(event) => onChange({ context_key: event.target.value })}
/>
</FormField>
}
</>
);
}
function DerivedReferenceField({
label,
fixedValue,
fieldValue,
match,
fixedOptions,
fields,
locked,
fixedDisabled = false,
onFixed,
onField,
onMatch
}: {
label: string;
fixedValue?: string;
fieldValue?: string;
match?: "id" | "slug";
fixedOptions: Array<{ id: string; name: string; slug: string }>;
fields: CampaignFieldDefinition[];
locked: boolean;
fixedDisabled?: boolean;
onFixed: (value: string) => void;
onField: (value: string) => void;
onMatch: (value: "id" | "slug") => void;
}) {
const usesField = fieldValue !== undefined;
return (
<>
<FormField label={`${label} source`}>
<select
value={usesField ? "field" : "fixed"}
disabled={locked}
onChange={(event) =>
event.target.value === "field"
? onField(fieldValue ?? fields[0]?.name ?? "")
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
}
>
<option value="fixed">Fixed value</option>
<option value="field">Campaign field</option>
</select>
</FormField>
{usesField ?
<>
<FormField label={`${label} field`}>
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
<option value="">Select a field</option>
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
</FormField>
<FormField label="Field contains">
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
<option value="id">Stable ID</option>
<option value="slug">Key / slug</option>
</select>
</FormField>
</> :
<FormField label={label}>
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
<option value="">Select {label.toLowerCase()}</option>
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
</select>
</FormField>
}
</>
);
}
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
if (!Array.isArray(value)) return [];
return value
.map(asRecord)
.map((target) => normalizeTarget({
id: String(target.id ?? ""),
mode: target.mode === "derived" ? "derived" : "direct",
label: optionalText(target.label),
postbox_id: optionalText(target.postbox_id),
address_key: optionalText(target.address_key),
template_id: optionalText(target.template_id),
organization_unit_id: optionalText(target.organization_unit_id),
organization_unit_field: optionalText(target.organization_unit_field),
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
function_id: optionalText(target.function_id),
function_field: optionalText(target.function_field),
function_match: target.function_match === "slug" ? "slug" : "id",
context_key: optionalText(target.context_key),
context_field: optionalText(target.context_field)
}))
.filter((target) => Boolean(target.id));
}
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
const common = {
id: target.id || newTargetId(),
mode: target.mode,
...(target.label?.trim() ? { label: target.label.trim() } : {})
};
if (target.mode === "direct") {
return {
...common,
mode: "direct",
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
};
}
return {
...common,
mode: "derived",
template_id: target.template_id ?? "",
...(target.organization_unit_field !== undefined
? {
organization_unit_field: target.organization_unit_field,
organization_unit_match: target.organization_unit_match ?? "id"
}
: { organization_unit_id: target.organization_unit_id ?? "" }),
...(target.function_field !== undefined
? {
function_field: target.function_field,
function_match: target.function_match ?? "id"
}
: { function_id: target.function_id ?? "" }),
...(target.context_field !== undefined
? { context_field: target.context_field }
: target.context_key !== undefined
? { context_key: target.context_key }
: {})
};
}
function targetIsComplete(target: CampaignPostboxTarget): boolean {
if (!target.id) return false;
if (target.mode === "direct") {
return Boolean(target.postbox_id || target.address_key);
}
return Boolean(
target.template_id
&& (target.organization_unit_id || target.organization_unit_field)
&& (target.function_id || target.function_field)
);
}
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
return {
id,
mode: "direct",
postbox_id: catalog.postboxes[0]?.id ?? ""
};
}
function derivedTargetDefaults(
id: string,
catalog: CampaignPostboxCatalog,
fields: CampaignFieldDefinition[]
): CampaignPostboxTarget {
const unit = catalog.organization_units[0];
return {
id,
mode: "derived",
template_id: catalog.templates[0]?.id ?? "",
...(unit
? {
organization_unit_id: unit.id,
function_id: unit.functions[0]?.id ?? ""
}
: {
organization_unit_field: fields[0]?.name ?? "",
organization_unit_match: "id",
function_field: fields[0]?.name ?? "",
function_match: "id"
})
};
}
function moveTarget(
targets: CampaignPostboxTarget[],
source: number,
destination: number
): CampaignPostboxTarget[] {
if (destination < 0 || destination >= targets.length) return targets;
const next = [...targets];
const [target] = next.splice(source, 1);
next.splice(destination, 0, target);
return next;
}
function optionalText(value: unknown): string | undefined {
const text = String(value ?? "").trim();
return text || undefined;
}
function newTargetId(): string {
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `postbox-${suffix}`;
}