Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -1,16 +1,17 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -20,7 +21,7 @@ import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
@@ -28,6 +29,7 @@ type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
@@ -61,12 +63,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
useEffect(() => {
if (!filesModuleInstalled) {
setFileSpaces([]);
return;
}
let cancelled = false;
void listFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
@@ -220,7 +226,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
@@ -233,15 +239,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Attachment sources">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
<div className="admin-table-surface attachment-sources-table-surface">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
</div>
</Card>
<Card title="ZIP attachments" collapsible>
@@ -253,26 +261,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
onChange={setZipEnabled}
/>
</div>
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
<div className="admin-table-surface attachment-zip-table-surface">
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
</div>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
)}
@@ -282,7 +292,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
</Card>
<Card title="Global Attachments">
<Card title="Global Attachments" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
@@ -292,23 +302,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<Card title="Statistics" collapsible>
<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>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
<div><dt>Upload support</dt><dd>{filesModuleInstalled ? "Connected through Files" : "Manual paths"}</dd></div>
</dl>
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
<p className="muted small-note">{filesModuleInstalled ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
@@ -340,7 +348,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && (
{pathChooser && filesModuleInstalled && (
<ManagedFileChooser
open
settings={settings}
@@ -546,6 +554,7 @@ type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
filesModuleInstalled: boolean;
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
@@ -554,7 +563,7 @@ type AttachmentSourceColumnContext = {
setPathChooser: (state: PathChooserState | null) => void;
};
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
@@ -568,20 +577,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={formatAttachmentSourcePath(basePath, fileSpaces)}
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked}
readOnly
tabIndex={-1}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder="attachments"
onClick={() => !locked && setPathChooser({ index })}
onChange={(event) => {
if (!filesModuleInstalled) patchBasePath(index, { path: event.target.value, source: "" });
}}
onClick={() => filesModuleInstalled && !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (!locked && (event.key === "Enter" || event.key === " ")) {
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)