Files
govoplan-campaign/webui/src/features/campaigns/components/TemplatePlaceholderControls.tsx
2026-06-26 01:39:19 +02:00

163 lines
5.7 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
name: string;
label: string;
};
export function TemplateFieldChipList({
namespace,
fields,
usedPlaceholders = [],
empty,
disabled = false,
onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (fields.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{fields.map((field) => {
const used = usedPlaceholders.some((placeholder) => placeholder.validNamespace && placeholder.namespace === namespace && placeholder.name === field.name);
return (
<button
type="button"
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
onClick={() => onInsert(namespace, field.name)}
>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button>
);
})}
</div>
);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) {
if (items.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}
>
{item.display}
</button>
))}
</div>
);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
localFields = [],
globalFields = [],
onCancel,
onRemove,
onReplace,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
localFields?: TemplateFieldOption[];
globalFields?: TemplateFieldOption[];
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
const replacementOptions = useMemo(
() => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
],
[globalFields, localFields]
);
const [replacement, setReplacement] = useState("");
const firstReplacement = replacementOptions[0];
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
useEffect(() => {
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
function replaceWithSelected() {
if (!field || !onReplace || !effectiveReplacement) return;
const separator = effectiveReplacement.indexOf(":");
if (separator < 0) return;
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
const name = effectiveReplacement.slice(separator + 1);
onReplace(field, namespace, name);
}
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
>
{field && (
<>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{field.reason === "invalid-namespace" && (
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)}
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
{onReplace && replacementOptions.length > 0 && (
<label className="template-replacement-field">
<span>Replace by existing field</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) => (
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name}
</option>
))}
</select>
</label>
)}
</>
)}
</Dialog>
);
}