initial commit after split
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../../../components/Button";
|
||||
import Dialog from "../../../components/Dialog";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
import {
|
||||
buildUndefinedPlaceholders,
|
||||
extractTemplatePlaceholders,
|
||||
removePlaceholderFromText,
|
||||
type TemplateNamespace,
|
||||
type UndefinedPlaceholder
|
||||
} from "../utils/templatePlaceholders";
|
||||
import {
|
||||
TemplateFieldChipList,
|
||||
UndefinedPlaceholderDecisionDialog,
|
||||
UndefinedPlaceholderList,
|
||||
type TemplateFieldOption
|
||||
} from "./TemplatePlaceholderControls";
|
||||
|
||||
export default function TemplateExpressionEditorDialog({
|
||||
open,
|
||||
title,
|
||||
value,
|
||||
localFields,
|
||||
globalFields,
|
||||
placeholder,
|
||||
description,
|
||||
saveLabel = "Save",
|
||||
validate,
|
||||
onCancel,
|
||||
onSave,
|
||||
onAddField,
|
||||
canAddField = () => true
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
value: string;
|
||||
localFields: TemplateFieldOption[];
|
||||
globalFields: TemplateFieldOption[];
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
saveLabel?: string;
|
||||
validate?: (value: string) => string;
|
||||
onCancel: () => void;
|
||||
onSave: (value: string) => void;
|
||||
onAddField: (name: string) => void;
|
||||
canAddField?: (name: string) => boolean;
|
||||
}) {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraftValue(value);
|
||||
setUndefinedDialog(null);
|
||||
}, [open, value]);
|
||||
|
||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(draftValue), [draftValue]);
|
||||
const localNames = useMemo(() => new Set(localFields.map((field) => field.name)), [localFields]);
|
||||
const globalNames = useMemo(() => new Set(globalFields.map((field) => field.name)), [globalFields]);
|
||||
const availableNames = useMemo(() => new Set([...localNames, ...globalNames]), [globalNames, localNames]);
|
||||
const undefinedPlaceholders = useMemo(
|
||||
() => buildUndefinedPlaceholders(usedPlaceholders, availableNames, { local: localNames, global: globalNames }),
|
||||
[availableNames, globalNames, localNames, usedPlaceholders]
|
||||
);
|
||||
const validationMessage = validate?.(draftValue) ?? "";
|
||||
|
||||
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
|
||||
const input = inputRef.current;
|
||||
const token = `{{${namespace}:${name}}}`;
|
||||
const defaultPosition = draftValue.toLocaleLowerCase().endsWith(".zip") ? draftValue.length - 4 : draftValue.length;
|
||||
const start = input?.selectionStart ?? defaultPosition;
|
||||
const end = input?.selectionEnd ?? start;
|
||||
setDraftValue(`${draftValue.slice(0, start)}${token}${draftValue.slice(end)}`);
|
||||
window.requestAnimationFrame(() => {
|
||||
input?.focus();
|
||||
const cursor = start + token.length;
|
||||
input?.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
function requestSave() {
|
||||
if (validationMessage) return;
|
||||
if (undefinedPlaceholders.length > 0) {
|
||||
setUndefinedDialog(undefinedPlaceholders[0]);
|
||||
return;
|
||||
}
|
||||
onSave(draftValue.trim());
|
||||
}
|
||||
|
||||
function removeUndefined(field: UndefinedPlaceholder) {
|
||||
setDraftValue((current) => removePlaceholderFromText(current, field.raw));
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
function addUndefined(field: UndefinedPlaceholder) {
|
||||
if (!field.name || field.reason === "invalid-namespace") return;
|
||||
onAddField(field.name);
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<Dialog
|
||||
open={open && !undefinedDialog}
|
||||
title={title}
|
||||
closeLabel="Cancel filename changes"
|
||||
className="template-expression-dialog"
|
||||
headerClassName="template-expression-dialog-header"
|
||||
bodyClassName="template-expression-dialog-body"
|
||||
footerClassName="template-expression-dialog-footer"
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{description && <p className="muted template-expression-description">{description}</p>}
|
||||
<label className="template-expression-value-field">
|
||||
<span>Archive filename</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={Boolean(validationMessage) || undefined}
|
||||
onChange={(event) => setDraftValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
requestSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
|
||||
|
||||
<h3 className="section-mini-heading">Recipient fields</h3>
|
||||
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
|
||||
<TemplateFieldChipList
|
||||
namespace="local"
|
||||
fields={localFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No recipient fields are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
|
||||
<h3 className="section-mini-heading">Campaign fields</h3>
|
||||
<TemplateFieldChipList
|
||||
namespace="global"
|
||||
fields={globalFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
|
||||
<h3 className="section-mini-heading">Used, but undefined</h3>
|
||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||
</Dialog>
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="archive filename"
|
||||
removeLabel="Remove from filename"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removeUndefined}
|
||||
onAddField={addUndefined}
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
|
||||
/>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user