163 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|