UI changes, attachment page redesign
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
type AttachmentRule = Record<string, unknown>;
|
||||
type PathChooserState = { index: number };
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -22,11 +20,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [localError, setLocalError] = useState("");
|
||||
|
||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const attachments = asRecord(draft?.attachments);
|
||||
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||
const entries = asRecord(draft?.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
|
||||
@@ -46,31 +46,43 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function patchGlobalAttachment(index: number, patchValue: Partial<AttachmentRule>) {
|
||||
const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule);
|
||||
patch(["attachments", "global"], nextRules);
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
|
||||
setDraft((current) => {
|
||||
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
|
||||
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
|
||||
});
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function addGlobalAttachment() {
|
||||
const nextRules: AttachmentRule[] = [
|
||||
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
|
||||
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
||||
}
|
||||
|
||||
function addBasePath() {
|
||||
patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]);
|
||||
}
|
||||
|
||||
function removeBasePath(index: number) {
|
||||
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function addGlobalAttachmentRule() {
|
||||
if (locked) return;
|
||||
const firstBasePath = basePaths[0]?.path ?? "";
|
||||
patch(["attachments", "global"], [
|
||||
...globalRules,
|
||||
{
|
||||
id: `global-${Date.now()}`,
|
||||
id: `attachment-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: "",
|
||||
base_dir: firstBasePath,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false,
|
||||
allow_multiple: false,
|
||||
missing_behavior: getText(attachments, "missing_behavior", "ask"),
|
||||
ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask")
|
||||
include_subdirs: false
|
||||
}
|
||||
];
|
||||
patch(["attachments", "global"], nextRules);
|
||||
}
|
||||
|
||||
function removeGlobalAttachment(index: number) {
|
||||
patch(["attachments", "global"], globalRules.filter((_, currentIndex) => currentIndex !== index));
|
||||
]);
|
||||
}
|
||||
|
||||
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
|
||||
@@ -126,123 +138,155 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Attachment area">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Attachment base path</dt><dd>{String(attachments.base_path || ".")}</dd></div>
|
||||
<div><dt>Global files</dt><dd>{globalRules.length}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Campaign file storage">
|
||||
<p className="muted">This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Upload campaign files</span>
|
||||
<span>Pick files for global attachment rules</span>
|
||||
<span>Resolve missing or ambiguous individual matches</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Global attachment files" actions={<Button onClick={addGlobalAttachment} disabled={locked}>Add file</Button>}>
|
||||
<div className="attachment-base-stack">
|
||||
<div className="attachment-base-grid">
|
||||
<FormField label="Attachment base path"><input value={getText(attachments, "base_path", ".")} disabled={locked} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||
<div className="attachment-base-toggle"><ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} /></div>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Default missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<FormField label="Default ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{globalRules.length === 0 ? (
|
||||
<p className="muted small-note">No global files selected. Add files here only if every message should include them.</p>
|
||||
) : (
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table direct-attachment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Base dir</th>
|
||||
<th>Selected file / pattern</th>
|
||||
<th>Required</th>
|
||||
<th>Include subdirs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{globalRules.map((rule, index) => (
|
||||
<tr key={String(rule.id ?? index)}>
|
||||
<td><input value={getText(rule, "label")} disabled={locked} placeholder="Attachment label" onChange={(event) => patchGlobalAttachment(index, { label: event.target.value })} /></td>
|
||||
<td><input value={getText(rule, "base_dir")} disabled={locked} placeholder="optional/folder" onChange={(event) => patchGlobalAttachment(index, { base_dir: event.target.value })} /></td>
|
||||
<td><input value={getText(rule, "file_filter")} disabled={locked} placeholder="file.pdf or {{field}}.pdf" onChange={(event) => patchGlobalAttachment(index, { file_filter: event.target.value })} /></td>
|
||||
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { required: checked })} /></td>
|
||||
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { include_subdirs: checked })} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeGlobalAttachment(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Per-recipient file patterns" actions={<Link to="../recipients"><Button>Open recipients</Button></Link>}>
|
||||
<p className="muted small-note">Individual file patterns can be edited on each recipient row. They are summarized here because file matching and upload review also belong to the Attachments workflow.</p>
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table files-table">
|
||||
<Card
|
||||
title="Attachment sources"
|
||||
actions={<Button variant="primary" onClick={addBasePath} disabled={locked}>Add base path</Button>}
|
||||
>
|
||||
<div className="app-table-wrap attachment-sources-table-wrap">
|
||||
<table className="app-table attachment-sources-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipient / entry</th>
|
||||
<th>Label</th>
|
||||
<th>Base dir</th>
|
||||
<th>Filter</th>
|
||||
<th>Options</th>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Individual attachments</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{individualRules.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted">No per-recipient file patterns are configured yet.</td></tr>
|
||||
)}
|
||||
{individualRules.map((rule, index) => <RuleRow key={`individual-${index}`} scope={`Entry ${rule.entry}`} rule={rule} />)}
|
||||
{basePaths.map((basePath, index) => (
|
||||
<tr key={basePath.id}>
|
||||
<td><input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} /></td>
|
||||
<td>
|
||||
<div className="field-with-action">
|
||||
<input value={basePath.path} disabled={locked} readOnly placeholder="attachments" />
|
||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
|
||||
</div>
|
||||
</td>
|
||||
<td><ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Global Attachments"
|
||||
actions={<Button variant="primary" onClick={addGlobalAttachmentRule} disabled={locked}>Add file</Button>}
|
||||
>
|
||||
<AttachmentRulesTable
|
||||
rules={globalRules}
|
||||
disabled={locked}
|
||||
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
||||
basePaths={basePaths}
|
||||
showAddButton={false}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Statistics">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.</p>
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pathChooser && (
|
||||
<MockPathChooserOverlay
|
||||
onClose={() => setPathChooser(null)}
|
||||
onSelect={(path) => {
|
||||
patchBasePath(pathChooser.index, path);
|
||||
setPathChooser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({ scope, rule }: { scope: string; rule: Record<string, unknown> }) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{scope}</td>
|
||||
<td>{String(rule.label || rule.id || "—")}</td>
|
||||
<td><code>{String(rule.base_dir || "—")}</code></td>
|
||||
<td><code>{String(rule.file_filter || "—")}</code></td>
|
||||
<td><code>{stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)}</code></td>
|
||||
</tr>
|
||||
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
|
||||
const paths: Array<Partial<AttachmentBasePath> & { label: string }> = [
|
||||
{ label: "Campaign attachments", path: "attachments" },
|
||||
{ label: "Campaign root", path: "." },
|
||||
{ label: "Shared group files", path: "group/shared" },
|
||||
{ label: "Tenant templates", path: "tenant/templates" },
|
||||
{ label: "Personal upload area", path: "user/uploads" }
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
||||
<div className="modal-panel attachment-rules-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="mock-path-chooser-title">Choose attachment base path</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
|
||||
<div className="placeholder-stack">
|
||||
{paths.map((path) => (
|
||||
<Button key={path.path} onClick={() => onSelect(path)}>
|
||||
{path.label}: <code>{path.path}</code>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function createBasePath(name: string, path: string): AttachmentBasePath {
|
||||
return {
|
||||
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name,
|
||||
path,
|
||||
allow_individual: false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.filter(isRecord).map((basePath, index) => ({
|
||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||
source: getText(basePath, "source"),
|
||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||
allow_individual: getBool(basePath, "allow_individual")
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual")
|
||||
}];
|
||||
}
|
||||
|
||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({
|
||||
id: getText(rule, "id", `global-${Math.random().toString(36).slice(2)}`),
|
||||
type: getText(rule, "type"),
|
||||
label: getText(rule, "label"),
|
||||
base_dir: getText(rule, "base_dir", ""),
|
||||
file_filter: getText(rule, "file_filter"),
|
||||
include_subdirs: getBool(rule, "include_subdirs"),
|
||||
required: getBool(rule, "required", true),
|
||||
allow_multiple: getBool(rule, "allow_multiple"),
|
||||
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
||||
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
||||
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
|
||||
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
|
||||
|
||||
@@ -127,7 +128,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function setGlobalValue(key: string, value: string) {
|
||||
function setGlobalValue(key: string, value: unknown) {
|
||||
patchGlobalValues({ ...globalValues, [key]: value });
|
||||
}
|
||||
|
||||
@@ -226,9 +227,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
</select>
|
||||
</td>
|
||||
<td><ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} /></td>
|
||||
<td><input value={valueToText(globalValues[field.name])} disabled={locked || !field.name} placeholder="Optional default" onChange={(event) => setGlobalValue(field.name, event.target.value)} /></td>
|
||||
<td><FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} /></td>
|
||||
<td><ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Delete</Button></td>
|
||||
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -300,9 +301,3 @@ function humanizeFieldName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function valueToText(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -128,10 +128,9 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
<FormField label="Ambiguous behavior">
|
||||
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
|
||||
</FormField>
|
||||
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
|
||||
</div>
|
||||
<p className="muted small-note">The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.</p>
|
||||
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
@@ -25,6 +25,13 @@ const recipientHeaderRows = [
|
||||
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
|
||||
];
|
||||
|
||||
type FieldDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
can_override: boolean;
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
@@ -38,13 +45,15 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
const entries = asRecord(draft?.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
|
||||
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
bcc: addressesFromValue(recipientsSection.bcc)
|
||||
};
|
||||
const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc");
|
||||
const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc");
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
@@ -53,7 +62,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
@@ -65,17 +73,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function appendRecipient(address: MailboxAddress) {
|
||||
const nextEntry = {
|
||||
id: `recipient-${inlineEntries.length + 1}`,
|
||||
function addRecipient() {
|
||||
const nextIndex = inlineEntries.length + 1;
|
||||
const newEntry = {
|
||||
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
|
||||
active: true,
|
||||
to: [address],
|
||||
name: address.name ?? "",
|
||||
email: address.email,
|
||||
to: [],
|
||||
name: "",
|
||||
email: "",
|
||||
fields: {},
|
||||
attachments: []
|
||||
};
|
||||
replaceInlineEntries([...inlineEntries, nextEntry]);
|
||||
replaceInlineEntries([...inlineEntries, newEntry]);
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
@@ -83,17 +92,22 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
to: address.email ? [address] : [],
|
||||
name: address.name ?? "",
|
||||
email: address.email
|
||||
}));
|
||||
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
|
||||
updateEntry(index, (entry) => {
|
||||
const nextEntry = { ...entry, [key]: addresses };
|
||||
if (key === "to") {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
return {
|
||||
...nextEntry,
|
||||
name: address.name ?? "",
|
||||
email: address.email
|
||||
};
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: string) {
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
@@ -103,13 +117,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, text: string) {
|
||||
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
|
||||
if (parsed.error) {
|
||||
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
|
||||
return;
|
||||
}
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
|
||||
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
@@ -195,80 +204,109 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
|
||||
<Card
|
||||
title="Recipients"
|
||||
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
||||
>
|
||||
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
||||
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
||||
)}
|
||||
{draft && (
|
||||
{draft && inlineEntries.length > 0 && (
|
||||
<div className="app-table-wrap recipient-table-wrap">
|
||||
<table className="app-table recipient-table recipient-editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Recipient</th>
|
||||
<th>Status</th>
|
||||
{fieldNames.map((field) => <th key={field}>{field}</th>)}
|
||||
<th>Individual attachments</th>
|
||||
<th>Recipients</th>
|
||||
<th>Attachments</th>
|
||||
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="recipient-add-row">
|
||||
<td className="mono-small">+</td>
|
||||
<td colSpan={Math.max(2, fieldNames.length + 3)}>
|
||||
<EmailAddressInput
|
||||
value={[]}
|
||||
suggestions={addressSuggestions}
|
||||
clearOnAdd
|
||||
disabled={locked || !draft}
|
||||
addLabel="Add recipient"
|
||||
emptyText="Add a new inline recipient."
|
||||
onAddressAdded={appendRecipient}
|
||||
/>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => {
|
||||
const recipient = primaryRecipient(entry);
|
||||
const to = addressesFromValue(entry.to);
|
||||
const cc = addressesFromValue(entry.cc);
|
||||
const bcc = addressesFromValue(entry.bcc);
|
||||
const fields = asRecord(entry.fields);
|
||||
const attachments = asArray(entry.attachments);
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small">{index + 1}</td>
|
||||
<td className="recipient-cell">
|
||||
<div className="recipient-address-stack">
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">To</span>
|
||||
<EmailAddressInput
|
||||
value={to.length ? to : fallbackRecipientAddress(entry)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel={to.length ? "Replace" : "Add"}
|
||||
emptyText="No recipient address."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "to", addresses)}
|
||||
/>
|
||||
</div>
|
||||
{allowIndividualCc && (
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">CC</span>
|
||||
<EmailAddressInput
|
||||
value={cc}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel="Add CC"
|
||||
emptyText="No CC."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "cc", addresses)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allowIndividualBcc && (
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">BCC</span>
|
||||
<EmailAddressInput
|
||||
value={bcc}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel="Add BCC"
|
||||
emptyText="No BCC."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "bcc", addresses)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
label="Active"
|
||||
checked={entry.active !== false}
|
||||
disabled={locked}
|
||||
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<EmailAddressInput
|
||||
value={recipient.email ? [recipient] : []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
compact
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
rules={attachments}
|
||||
disabled={locked}
|
||||
addLabel={recipient.email ? "Replace" : "Add"}
|
||||
emptyText="No recipient address."
|
||||
onChange={(addresses) => updateEntryRecipient(index, addresses)}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
</td>
|
||||
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
|
||||
{fieldNames.map((field) => (
|
||||
<td key={field}>
|
||||
<input
|
||||
{fieldDefinitions.map((field) => (
|
||||
<td key={field.name}>
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
value={String(fields[field] ?? "")}
|
||||
disabled={locked}
|
||||
onChange={(event) => updateEntryField(index, field, event.target.value)}
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "Uses global value" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<textarea
|
||||
className="recipient-attachments-input"
|
||||
rows={2}
|
||||
value={attachments.length ? stringifyJson(attachments) : "[]"}
|
||||
disabled={locked}
|
||||
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
|
||||
onChange={(event) => updateEntryAttachments(index, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
);
|
||||
@@ -288,15 +326,40 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
|
||||
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
|
||||
return asArray(draft?.fields)
|
||||
.map((field) => asRecord(field))
|
||||
.map((field) => String(field.name || field.id || ""))
|
||||
.filter(Boolean);
|
||||
.map((field) => ({
|
||||
name: getText(field, "name") || getText(field, "id"),
|
||||
label: getText(field, "label"),
|
||||
type: normalizeFieldType(getText(field, "type", "string")),
|
||||
can_override: getBool(field, "can_override", true)
|
||||
}))
|
||||
.filter((field) => Boolean(field.name));
|
||||
}
|
||||
|
||||
function primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
|
||||
const to = addressesFromValue(entry.to)[0];
|
||||
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
||||
return to ?? direct ?? { name: "", email: "" };
|
||||
function normalizeFieldType(value: string): string {
|
||||
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
|
||||
}
|
||||
|
||||
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
|
||||
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
||||
return direct?.email ? [direct] : [];
|
||||
}
|
||||
|
||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
||||
}
|
||||
|
||||
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
|
||||
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
if (!existing.has(preferred)) return preferred;
|
||||
let counter = entries.length + 1;
|
||||
let candidate = `recipient-${counter}`;
|
||||
while (existing.has(candidate)) {
|
||||
counter += 1;
|
||||
candidate = `recipient-${counter}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
<p className="mono-small">{saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Select template</Button>
|
||||
<Button disabled>Manage templates</Button>
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
@@ -239,6 +239,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
</FormField>
|
||||
)}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>Load from library</Button>
|
||||
<Button disabled>Save to library</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
246
src/features/campaigns/components/AttachmentRulesOverlay.tsx
Normal file
246
src/features/campaigns/components/AttachmentRulesOverlay.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../../../components/Button";
|
||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
export type AttachmentBasePath = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
};
|
||||
|
||||
type AttachmentRulesOverlayProps = {
|
||||
title: string;
|
||||
rules: AttachmentRule[];
|
||||
disabled?: boolean;
|
||||
buttonLabel?: string;
|
||||
emptyText?: string;
|
||||
basePaths?: AttachmentBasePath[];
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
|
||||
type AttachmentRulesTableProps = {
|
||||
rules: AttachmentRule[];
|
||||
disabled?: boolean;
|
||||
emptyText?: string;
|
||||
basePaths?: AttachmentBasePath[];
|
||||
showAddButton?: boolean;
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
|
||||
type FileChooserState = {
|
||||
ruleIndex: number;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
export default function AttachmentRulesOverlay({
|
||||
title,
|
||||
rules,
|
||||
disabled = false,
|
||||
buttonLabel,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
basePaths = [],
|
||||
onChange
|
||||
}: AttachmentRulesOverlayProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
||||
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
||||
|
||||
const dialog = open ? createPortal(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
||||
<div className="modal-panel attachment-rules-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="attachment-rules-title">{title}</h2>
|
||||
<button className="modal-close" onClick={() => setOpen(false)}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p>
|
||||
<AttachmentRulesTable
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
emptyText={emptyText}
|
||||
basePaths={basePaths}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button variant="primary" onClick={() => setOpen(false)}>Close</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className="attachment-summary-button" onClick={() => setOpen(true)} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
|
||||
{label}
|
||||
</Button>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentRulesTable({
|
||||
rules,
|
||||
disabled = false,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
basePaths = [],
|
||||
showAddButton = true,
|
||||
onChange
|
||||
}: AttachmentRulesTableProps) {
|
||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||
|
||||
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const firstBasePath = basePaths[0]?.path ?? "";
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `attachment-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: firstBasePath,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function openFileChooser(ruleIndex: number) {
|
||||
const rule = rules[ruleIndex] ?? {};
|
||||
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rules.length === 0 ? (
|
||||
<p className="muted attachment-rules-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="app-table-wrap attachment-rules-table-wrap">
|
||||
<table className="app-table attachment-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Base path</th>
|
||||
<th>File / pattern</th>
|
||||
<th>Required</th>
|
||||
<th>Subdirs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, index) => {
|
||||
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
||||
return (
|
||||
<tr key={String(rule.id ?? index)}>
|
||||
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
|
||||
<td>
|
||||
{basePaths.length > 0 ? (
|
||||
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
|
||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="field-with-action">
|
||||
<input value={getText(rule, "file_filter")} disabled={disabled} readOnly placeholder="file.pdf or {{local:id}}.pdf" />
|
||||
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
|
||||
</div>
|
||||
</td>
|
||||
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
|
||||
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{showAddButton && (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={addRule} disabled={disabled}>Add file</Button>
|
||||
</div>
|
||||
)}
|
||||
{fileChooser && (
|
||||
<MockFileChooserOverlay
|
||||
basePath={fileChooser.basePath}
|
||||
onSelect={(fileFilter) => {
|
||||
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
||||
setFileChooser(null);
|
||||
}}
|
||||
onClose={() => setFileChooser(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
||||
const files = [
|
||||
"welcome.pdf",
|
||||
"terms-and-conditions.pdf",
|
||||
"invoice_{{local:invoice_number}}.pdf",
|
||||
"{{local:recipient_id}}/certificate.pdf",
|
||||
"attachments/{{local:email}}/*.pdf"
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
|
||||
<div className="modal-panel attachment-rules-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="mock-file-chooser-title">Choose file or pattern</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded files below <code>{basePath || "."}</code>.</p>
|
||||
<div className="placeholder-stack">
|
||||
{files.map((file) => (
|
||||
<Button key={file} onClick={() => onSelect(file)}>
|
||||
<code>{file}</code>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
|
||||
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
|
||||
if (isDirectAttachmentRule(rule)) {
|
||||
summary.direct += 1;
|
||||
} else {
|
||||
summary.rules += 1;
|
||||
}
|
||||
return summary;
|
||||
}, { direct: 0, rules: 0 });
|
||||
}
|
||||
|
||||
function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
const explicitType = getText(rule, "type");
|
||||
if (explicitType === "direct") return true;
|
||||
if (explicitType === "pattern") return false;
|
||||
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
||||
if (!fileFilter) return false;
|
||||
return !/[{}*?\[\]]/.test(fileFilter);
|
||||
}
|
||||
59
src/features/campaigns/components/FieldValueInput.tsx
Normal file
59
src/features/campaigns/components/FieldValueInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
type FieldValueInputProps = {
|
||||
fieldType?: string;
|
||||
value: unknown;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) {
|
||||
const normalizedType = normalizeFieldType(fieldType);
|
||||
const inputType = inputTypeForField(normalizedType);
|
||||
const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined;
|
||||
|
||||
return (
|
||||
<input
|
||||
className={className}
|
||||
type={inputType}
|
||||
step={step}
|
||||
value={valueToInputText(value, normalizedType)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete={normalizedType === "password" ? "new-password" : undefined}
|
||||
onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFieldType(fieldType: string): string {
|
||||
return ["integer", "double", "date", "password"].includes(fieldType) ? fieldType : "string";
|
||||
}
|
||||
|
||||
function inputTypeForField(fieldType: string): string {
|
||||
if (fieldType === "integer" || fieldType === "double") return "number";
|
||||
if (fieldType === "date") return "date";
|
||||
if (fieldType === "password") return "password";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function valueToInputText(value: unknown, fieldType: string): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function inputValueToFieldValue(fieldType: string, value: string): unknown {
|
||||
if (value === "") return "";
|
||||
if (fieldType === "integer") {
|
||||
const numberValue = Number.parseInt(value, 10);
|
||||
return Number.isFinite(numberValue) ? numberValue : value;
|
||||
}
|
||||
if (fieldType === "double") {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user