UI polishes, file chooser
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -420,4 +420,7 @@ bin-release/
|
|||||||
# should NOT be excluded as they contain compiler settings and other important
|
# should NOT be excluded as they contain compiler settings and other important
|
||||||
# information for Eclipse / Flash Builder.
|
# information for Eclipse / Flash Builder.
|
||||||
|
|
||||||
.fuse_*
|
.fuse_*
|
||||||
|
|
||||||
|
multisealmail-*.zip
|
||||||
|
multi-seal-mail-webui*.tar.gz
|
||||||
Binary file not shown.
@@ -158,7 +158,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<td><input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} /></td>
|
<td><input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} /></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="field-with-action">
|
<div className="field-with-action">
|
||||||
<input value={basePath.path} disabled={locked} readOnly placeholder="attachments" />
|
<input
|
||||||
|
className="chooser-display-input"
|
||||||
|
value={basePath.path}
|
||||||
|
disabled={locked}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
placeholder="attachments"
|
||||||
|
onClick={() => !locked && setPathChooser({ index })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!locked && (event.key === "Enter" || event.key === " ")) {
|
||||||
|
event.preventDefault();
|
||||||
|
setPathChooser({ index });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
|
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
|
import type { ApiSettings } from "../../types";
|
||||||
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
|
import { formatDateTime } from "./utils/campaignView";
|
||||||
|
|
||||||
|
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
const version = data.currentVersion;
|
||||||
|
|
||||||
export default function CampaignAuditPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<h1>Audit log</h1>
|
<div>
|
||||||
|
<PageTitle loading={loading}>Audit log</PageTitle>
|
||||||
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
|
||||||
<Card title="Recent audit events">
|
<Card title="Recent audit events">
|
||||||
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import Card from "../../components/Card";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
|
||||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||||
|
|
||||||
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const campaignJson = getCampaignJson(data.currentVersion);
|
const version = data.currentVersion;
|
||||||
|
const campaignJson = getCampaignJson(version);
|
||||||
const campaign = asRecord(campaignJson.campaign);
|
const campaign = asRecord(campaignJson.campaign);
|
||||||
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
|
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
|
||||||
|
|
||||||
@@ -17,15 +18,16 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>JSON</PageTitle>
|
<PageTitle loading={loading}>JSON</PageTitle>
|
||||||
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!data.currentVersion}>Download JSON</Button>
|
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
<Card>
|
<Card>
|
||||||
{!loading || data.currentVersion ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import LoadingIndicator from "../../components/LoadingIndicator";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
||||||
import type { CampaignListItem } from "../../types";
|
import type { CampaignListItem } from "../../types";
|
||||||
|
|
||||||
@@ -58,21 +58,20 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
|
||||||
<Card
|
<div className="page-heading split workspace-heading">
|
||||||
title={<span className="card-heading-with-loader">All campaigns {loading && <LoadingIndicator label="Loading campaigns" />}</span>}
|
<div>
|
||||||
actions={
|
<PageTitle loading={loading}>All campaigns</PageTitle>
|
||||||
<div className="campaign-card-actions">
|
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
|
||||||
{lastLoadedAt && <span className="last-loaded">Last loaded: {lastLoadedAt}</span>}
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
|
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
|
||||||
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
|
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
|
||||||
{creating ? "Creating…" : "New campaign"}
|
{creating ? "Creating…" : "New campaign"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
>
|
<Card>
|
||||||
|
|
||||||
{!loading && campaigns.length === 0 && (
|
{!loading && campaigns.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h2>No campaigns yet</h2>
|
<h2>No campaigns yet</h2>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|||||||
import { formatDateTime } from "./utils/campaignView";
|
import { formatDateTime } from "./utils/campaignView";
|
||||||
|
|
||||||
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeCurrentVersion: false, includeSummary: true });
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
|
const version = data.currentVersion;
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Report</PageTitle>
|
<PageTitle loading={loading}>Report</PageTitle>
|
||||||
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at ?? data.summary?.generated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
|||||||
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||||
<Route path="audit" element={<CampaignAuditPage />} />
|
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="wizard/review" element={<ReviewWizard />} />
|
<Route path="wizard/review" element={<ReviewWizard />} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, vers
|
|||||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||||
import {
|
import {
|
||||||
addressesFromValue,
|
addressesFromValue,
|
||||||
collectCampaignAddressSuggestions,
|
collectCampaignAddressSuggestions,
|
||||||
@@ -46,6 +46,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||||
const source = asRecord(entries.source);
|
const source = asRecord(entries.source);
|
||||||
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
|
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
|
||||||
|
const attachmentSection = asRecord(draft?.attachments);
|
||||||
|
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [draft?.attachments]);
|
||||||
|
const individualAttachmentBasePaths = useMemo(() => {
|
||||||
|
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
||||||
|
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
||||||
|
}, [attachmentBasePaths]);
|
||||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
||||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||||
to: addressesFromValue(recipientsSection.to),
|
to: addressesFromValue(recipientsSection.to),
|
||||||
@@ -292,6 +298,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
title={`Attachments for recipient ${index + 1}`}
|
title={`Attachments for recipient ${index + 1}`}
|
||||||
rules={attachments}
|
rules={attachments}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
|
basePaths={individualAttachmentBasePaths}
|
||||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -347,6 +354,25 @@ function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddres
|
|||||||
return direct?.email ? [direct] : [];
|
return direct?.email ? [direct] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentBasePaths(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", true)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Review</PageTitle>
|
<PageTitle loading={loading}>Review</PageTitle>
|
||||||
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, getDeliverySection, getNestedString } from "./utils/campaignView";
|
import { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
|
||||||
|
|
||||||
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
|
const version = data.currentVersion;
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
const delivery = getDeliverySection(data.currentVersion);
|
const delivery = getDeliverySection(version);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const retry = asRecord(delivery.retry);
|
const retry = asRecord(delivery.retry);
|
||||||
@@ -20,6 +21,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Send</PageTitle>
|
<PageTitle loading={loading}>Send</PageTitle>
|
||||||
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type AttachmentRulesTableProps = {
|
|||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
basePaths?: AttachmentBasePath[];
|
basePaths?: AttachmentBasePath[];
|
||||||
showAddButton?: boolean;
|
showAddButton?: boolean;
|
||||||
|
activeChooserRuleIndex?: number | null;
|
||||||
|
onOpenFileChooser?: (ruleIndex: number) => void;
|
||||||
onChange: (rules: AttachmentRule[]) => void;
|
onChange: (rules: AttachmentRule[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,28 +49,70 @@ export default function AttachmentRulesOverlay({
|
|||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesOverlayProps) {
|
}: AttachmentRulesOverlayProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||||
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
||||||
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
||||||
|
|
||||||
|
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||||
|
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFileChooser(ruleIndex: number) {
|
||||||
|
const rule = rules[ruleIndex] ?? {};
|
||||||
|
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFileFilter(fileFilter: string) {
|
||||||
|
if (!fileChooser) return;
|
||||||
|
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
||||||
|
setFileChooser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverlay() {
|
||||||
|
setFileChooser(null);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
|
||||||
|
const activeBasePath = fileChooser
|
||||||
|
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
const dialog = open ? createPortal(
|
const dialog = open ? createPortal(
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
||||||
<div className="modal-panel attachment-rules-modal">
|
<div className="modal-panel attachment-rules-modal">
|
||||||
<header className="modal-header">
|
<header className="modal-header">
|
||||||
<h2 id="attachment-rules-title">{title}</h2>
|
<h2 id="attachment-rules-title">{fileChooser ? "Choose file or pattern" : title}</h2>
|
||||||
<button className="modal-close" onClick={() => setOpen(false)}>×</button>
|
<button className="modal-close" onClick={closeOverlay}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body attachment-rules-body">
|
<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>
|
{fileChooser ? (
|
||||||
<AttachmentRulesTable
|
<MockFileChooserContent
|
||||||
rules={rules}
|
basePath={activeBasePath}
|
||||||
disabled={disabled}
|
onSelect={selectFileFilter}
|
||||||
emptyText={emptyText}
|
onClose={() => setFileChooser(null)}
|
||||||
basePaths={basePaths}
|
/>
|
||||||
onChange={onChange}
|
) : (
|
||||||
/>
|
<>
|
||||||
|
<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}
|
||||||
|
activeChooserRuleIndex={null}
|
||||||
|
onOpenFileChooser={openFileChooser}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
<Button variant="primary" onClick={() => setOpen(false)}>Close</Button>
|
{fileChooser ? (
|
||||||
|
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" onClick={closeOverlay}>Close</Button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -91,6 +135,8 @@ export function AttachmentRulesTable({
|
|||||||
emptyText = "No attachment files or matching rules configured yet.",
|
emptyText = "No attachment files or matching rules configured yet.",
|
||||||
basePaths = [],
|
basePaths = [],
|
||||||
showAddButton = true,
|
showAddButton = true,
|
||||||
|
activeChooserRuleIndex = null,
|
||||||
|
onOpenFileChooser,
|
||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||||
@@ -115,90 +161,112 @@ export function AttachmentRulesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeRule(index: number) {
|
function removeRule(index: number) {
|
||||||
|
setFileChooser(null);
|
||||||
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
function openFileChooser(ruleIndex: number) {
|
||||||
|
if (onOpenFileChooser) {
|
||||||
|
onOpenFileChooser(ruleIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rule = rules[ruleIndex] ?? {};
|
const rule = rules[ruleIndex] ?? {};
|
||||||
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectFileFilter(fileFilter: string) {
|
||||||
|
if (!fileChooser) return;
|
||||||
|
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
||||||
|
setFileChooser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
|
||||||
|
const activeBasePath = fileChooser
|
||||||
|
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="attachment-rules-editor">
|
||||||
{rules.length === 0 ? (
|
<div className="attachment-rules-main">
|
||||||
<p className="muted attachment-rules-empty">{emptyText}</p>
|
{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">
|
<div className="app-table-wrap attachment-rules-table-wrap">
|
||||||
<thead>
|
<table className="app-table attachment-rules-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Label</th>
|
<tr>
|
||||||
<th>Base path</th>
|
<th>Label</th>
|
||||||
<th>File / pattern</th>
|
<th>Base path</th>
|
||||||
<th>Required</th>
|
<th>File / pattern</th>
|
||||||
<th>Subdirs</th>
|
<th>Required</th>
|
||||||
<th></th>
|
<th>Subdirs</th>
|
||||||
</tr>
|
<th></th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{rules.map((rule, index) => {
|
<tbody>
|
||||||
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
{rules.map((rule, index) => {
|
||||||
return (
|
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
||||||
<tr key={String(rule.id ?? index)}>
|
const isChoosingFile = (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index;
|
||||||
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
|
return (
|
||||||
<td>
|
<tr key={String(rule.id ?? index)} className={isChoosingFile ? "is-choosing-file" : undefined}>
|
||||||
{basePaths.length > 0 ? (
|
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
|
||||||
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
|
<td>
|
||||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
|
{basePaths.length > 0 ? (
|
||||||
</select>
|
<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>)}
|
||||||
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
|
</select>
|
||||||
)}
|
) : (
|
||||||
</td>
|
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
|
||||||
<td>
|
)}
|
||||||
<div className="field-with-action">
|
</td>
|
||||||
<input value={getText(rule, "file_filter")} disabled={disabled} readOnly placeholder="file.pdf or {{local:id}}.pdf" />
|
<td>
|
||||||
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
|
<div className="field-with-action">
|
||||||
</div>
|
<input
|
||||||
</td>
|
className="chooser-display-input"
|
||||||
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
|
value={getText(rule, "file_filter")}
|
||||||
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
|
disabled={disabled}
|
||||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
|
readOnly
|
||||||
</tr>
|
tabIndex={-1}
|
||||||
);
|
placeholder="file.pdf or {{local:id}}.pdf"
|
||||||
})}
|
onClick={() => !disabled && openFileChooser(index)}
|
||||||
</tbody>
|
onKeyDown={(event) => {
|
||||||
</table>
|
if (!disabled && (event.key === "Enter" || event.key === " ")) {
|
||||||
</div>
|
event.preventDefault();
|
||||||
)}
|
openFileChooser(index);
|
||||||
{showAddButton && (
|
}
|
||||||
<div className="button-row compact-actions">
|
}}
|
||||||
<Button onClick={addRule} disabled={disabled}>Add file</Button>
|
/>
|
||||||
</div>
|
<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 attachment-rules-footer-actions">
|
||||||
|
<Button onClick={addRule} disabled={disabled}>Add file</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{fileChooser && (
|
{fileChooser && (
|
||||||
<MockFileChooserOverlay
|
<MockFileChooserOverlay
|
||||||
basePath={fileChooser.basePath}
|
basePath={activeBasePath}
|
||||||
onSelect={(fileFilter) => {
|
onSelect={selectFileFilter}
|
||||||
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
|
||||||
setFileChooser(null);
|
|
||||||
}}
|
|
||||||
onClose={() => setFileChooser(null)}
|
onClose={() => setFileChooser(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
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(
|
return createPortal(
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
|
||||||
<div className="modal-panel attachment-rules-modal">
|
<div className="modal-panel attachment-rules-modal">
|
||||||
@@ -207,14 +275,7 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
|
|||||||
<button className="modal-close" onClick={onClose}>×</button>
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body attachment-rules-body">
|
<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>
|
<MockFileChooserContent basePath={basePath} onSelect={onSelect} onClose={onClose} />
|
||||||
<div className="placeholder-stack">
|
|
||||||
{files.map((file) => (
|
|
||||||
<Button key={file} onClick={() => onSelect(file)}>
|
|
||||||
<code>{file}</code>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
@@ -225,6 +286,32 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MockFileChooserContent({ 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 (
|
||||||
|
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
|
||||||
|
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
|
||||||
|
<div className="placeholder-stack attachment-file-browser-list">
|
||||||
|
{files.map((file) => (
|
||||||
|
<Button key={file} onClick={() => onSelect(file)}>
|
||||||
|
<code>{file}</code>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions attachment-file-browser-actions">
|
||||||
|
<Button onClick={onClose}>Back</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
|
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
|
||||||
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
|
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
|
||||||
if (isDirectAttachmentRule(rule)) {
|
if (isDirectAttachmentRule(rule)) {
|
||||||
|
|||||||
@@ -829,3 +829,92 @@
|
|||||||
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
|
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
|
||||||
.recipient-address-stack { min-width: 260px; }
|
.recipient-address-stack { min-width: 260px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Attachment file chooser panel embedded in the shared rules editor. */
|
||||||
|
.attachment-rules-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.attachment-rules-editor.has-file-browser {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.attachment-rules-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.attachment-rules-table tr.is-choosing-file td {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.attachment-rules-footer-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-header h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-list .btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.attachment-file-browser-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.attachment-rules-editor.has-file-browser {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chooser-backed readonly fields: legible, clickable display values without a text caret. */
|
||||||
|
.chooser-display-input,
|
||||||
|
.field-with-action input.chooser-display-input {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line-dark);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
caret-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chooser-display-input:hover,
|
||||||
|
.field-with-action input.chooser-display-input:hover {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chooser-display-input:focus,
|
||||||
|
.field-with-action input.chooser-display-input:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(239, 107, 58, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chooser-display-input:disabled,
|
||||||
|
.field-with-action input.chooser-display-input:disabled {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user