568 lines
18 KiB
TypeScript
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}`;
|
|
}
|