chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent 57f6066bf7
commit 52e6ed49c5
66 changed files with 8868 additions and 4072 deletions

View File

@@ -8,7 +8,7 @@ import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
@@ -57,7 +57,7 @@ export default function AttachmentRulesOverlay({
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
@@ -91,13 +91,13 @@ export default function AttachmentRulesOverlay({
className="attachment-rules-modal"
bodyClassName="attachment-rules-body"
onClose={cancelOverlay}
footer={(
<>
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
footer={
<>
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
</>
)}
>
}>
<AttachmentRulesTable
rules={draftRules}
settings={settings}
@@ -109,20 +109,20 @@ export default function AttachmentRulesOverlay({
filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
onChange={setDraftRules} />
</Dialog>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
{label}
</Button>
{dialog}
</>
);
</>);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
@@ -141,8 +141,8 @@ export function AttachmentRulesTable({
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
</div>
</div>
);
</div>);
}
export function AttachmentRulesDataGrid({
@@ -150,7 +150,7 @@ export function AttachmentRulesDataGrid({
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
@@ -196,10 +196,10 @@ export function AttachmentRulesDataGrid({
const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId)
?? basePaths.find((item) => item.path === currentPath)
?? (!explicitlyReferenced ? basePaths[0] : undefined)
?? null;
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
basePaths.find((item) => item.path === currentPath) ?? (
!explicitlyReferenced ? basePaths[0] : undefined) ??
null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
@@ -225,30 +225,30 @@ export function AttachmentRulesDataGrid({
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && ManagedFileChooser && (
<ManagedFileChooser
open
settings={settings}
storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>
)}
</>
);
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
{fileChooser && ManagedFileChooser &&
<ManagedFileChooser
open
settings={settings}
storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment} />
}
</>);
}
type AttachmentRuleColumnContext = {
@@ -267,122 +267,154 @@ type AttachmentRuleColumnContext = {
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "Base path",
width: 250,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
?? (!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}
>
{!selectedBasePath && (
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
)}
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "i18n:govoplan-campaign.base_path.6a4867ca",
width: 250,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}>
{!selectedBasePath &&
<option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>
);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
</select>);
},
{
id: "file_filter",
header: "File / pattern",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) => (
<div className="field-with-action split-field-action">
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "i18n:govoplan-campaign.file_pattern.86320b07",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"}
onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}}
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
</div>
),
value: (rule) => getText(rule, "file_filter")
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}}
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}} />
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
</div>,
value: (rule) => getText(rule, "file_filter")
},
{
id: "message_filename_template",
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "message_filename_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
value: (rule) => getText(rule, "message_filename_template")
},
{
id: "zip_entry_name_template",
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "zip_entry_name_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
value: (rule) => getText(rule, "zip_entry_name_template")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
},
...(zipConfig.enabled ? [{
id: "zip",
header: "ZIP archive",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "Exclude from ZIP" },
{ value: "inherit", label: "Campaign standard" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
>
<option value="exclude">Exclude from ZIP</option>
<option value="inherit">Campaign standard</option>
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select>
);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_rule, index) => (
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below"
removeLabel="Remove attachment"
moveUpLabel="Move attachment up"
moveDownLabel="Move attachment down"
/>
)
}
];
</select>);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_rule, index) =>
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
}];
}

View File

@@ -7,15 +7,15 @@ import {
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
type CampaignShareTargets } from
"../../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
@@ -23,12 +23,12 @@ export default function CampaignAccessCard({
campaign,
onChanged,
onError
}: {
settings: ApiSettings;
campaign: CampaignListItem;
onChanged: () => Promise<void>;
onError: (message: string) => void;
}) {
}: {settings: ApiSettings;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]);
@@ -41,20 +41,33 @@ export default function CampaignAccessCard({
const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [busy, setBusy] = useState(false);
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
useUnsavedDraftGuard({
dirty: ownerDirty || shareDirty,
onSave: saveActiveDialog,
onDiscard: () => {
closeOwnerDialog();
closeShareDialog();
}
});
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)
]);
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)]
);
setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at));
setAvailable(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith("403 ")) setAvailable(false);
else onError(message);
if (message.startsWith("403 ")) setAvailable(false);else
onError(message);
}
}
@@ -67,38 +80,59 @@ export default function CampaignAccessCard({
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
]), [targets]);
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
), [targets]);
const ownerLabel = campaign.owner_group_id
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
const ownerLabel = campaign.owner_group_id ?
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
async function saveOwner() {
if (!ownerId) return;
function closeOwnerDialog() {
setOwnerOpen(false);
setOwnerType(currentOwnerType);
setOwnerId(currentOwnerId);
}
function closeShareDialog() {
setShareOpen(false);
setShareType("user");
setShareTargetId("");
setSharePermission("read");
}
async function saveActiveDialog(): Promise<boolean> {
if (ownerDirty) return saveOwner();
if (shareDirty) return saveShare();
return true;
}
async function saveOwner(): Promise<boolean> {
if (!ownerId) return false;
setBusy(true);
try {
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
? { owner_user_id: ownerId, owner_group_id: null }
: { owner_user_id: null, owner_group_id: ownerId });
await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
{ owner_user_id: ownerId, owner_group_id: null } :
{ owner_user_id: null, owner_group_id: ownerId });
setOwnerOpen(false);
await onChanged();
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
return true;
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
}
async function saveShare() {
if (!shareTargetId) return;
async function saveShare(): Promise<boolean> {
if (!shareTargetId) return false;
setBusy(true);
try {
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
setShareOpen(false);
setShareTargetId("");
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
return true;
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
}
async function revoke() {
@@ -108,52 +142,53 @@ export default function CampaignAccessCard({
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null);
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
} catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{
id: "target",
header: "Shared with",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
}
},
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
], [targetMap]);
{
id: "target",
header: "i18n:govoplan-campaign.shared_with.6203f449",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
}
},
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
[targetMap]);
if (available === false) return null;
return (
<>
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
<p><strong>Owner:</strong> {ownerLabel}</p>
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button></div>}>
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
<p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
</Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
</Dialog>
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>
);
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>);
}

View File

@@ -9,16 +9,16 @@ import {
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
type CampaignVersionListItem } from
"../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
isTemporaryUserLockedVersion } from
"../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
finalLock
});
async function runAction(action: Exclude<ConfirmAction, null>) {
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
setLocalMessage("i18n:govoplan-campaign.validation_lock_removed_validation_build_and_gen.ea26975f");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
setLocalMessage("i18n:govoplan-campaign.temporary_user_lock_removed_the_version_is_edita.0ca4c587");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
setLocalMessage("i18n:govoplan-campaign.version_permanently_locked_future_changes_requir.fd718a84");
}
await reload();
} catch (err) {
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null,
current_step: version.current_step ?? null
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
{localError && <span className="locked-version-error"> {localError}</span>}
</div>
<div className="button-row compact-actions locked-version-actions">
{validationLock && (
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"}
{validationLock &&
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock_validation.f01952b6"}
</Button>
)}
{temporaryUserLock && (
<>
}
{temporaryUserLock &&
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
i18n:govoplan-campaign.lock_permanently.cc0ce9e7
</Button>
</>
)}
{canCreateEditableCopy && (
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
}
{canCreateEditableCopy &&
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "i18n:govoplan-campaign.creating_copy.4247f587" : "i18n:govoplan-campaign.create_editable_copy.92d6d91b"}
</Button>
)}
}
</div>
<ConfirmDialog
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
const action = confirmAction;
setConfirmAction(null);
if (action) void runAction(action);
}}
/>
</DismissibleAlert>
);
}} />
</DismissibleAlert>);
}
type LockFlags = {
@@ -154,82 +154,82 @@ type LockFlags = {
};
function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags,
): { kind: string; title: string; description: string; info: string } {
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags)
: {kind: string;title: string;description: string;info: string;} {
if (flags.historicalVersion) {
return {
kind: "historical",
title: "Historical version.",
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
info: `Version #${version?.version_number ?? "—"}.`,
title: "i18n:govoplan-campaign.historical_version.adac5dba",
description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
info: `Version #${version?.version_number ?? "—"}.`
};
}
if (flags.temporaryUserLock) {
return {
kind: "temporary-user",
title: "Temporarily locked version.",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
description: "i18n:govoplan-campaign.campaign_data_is_read_only_but_an_authorized_use.f2e513b1",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : ""
};
}
if (flags.permanentUserLock) {
return {
kind: "permanent-user",
title: "Permanently locked version.",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
info: version?.user_locked_at || version?.published_at
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
: "",
title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
info: version?.user_locked_at || version?.published_at ?
`Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
""
};
}
if (flags.validationLock) {
return {
kind: "validation",
title: "Temporarily locked after validation.",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
};
}
if (flags.finalLock) {
return {
kind: "delivery",
title: "Permanently locked delivery snapshot.",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
};
}
return {
kind: "other",
title: "Locked version.",
description: "This version is read-only. Create an editable copy to continue working.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.locked_version.e755b179",
description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
};
}
function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?";
if (action === "unlock-user") return "Unlock temporary lock?";
if (action === "permanent") return "Lock permanently?";
return "Confirm action";
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
return "i18n:govoplan-campaign.confirm_action.50649c5c";
}
function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
return "i18n:govoplan-campaign.this_makes_the_version_editable_and_clears_valid.89a6cd1b";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
return "i18n:govoplan-campaign.this_removes_the_reversible_user_lock_campaign_d.fcf8cb1d";
}
if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.3054b076";
}
return "Continue?";
return "i18n:govoplan-campaign.continue.4e6302ed";
}
function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation";
if (action === "unlock-user") return "Unlock";
if (action === "permanent") return "Lock permanently";
return "Confirm";
}
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}

View File

@@ -41,7 +41,7 @@ export type CampaignMessagePreviewOverlayProps = {
};
export default function CampaignMessagePreviewOverlay({
title = "Message preview",
title = "i18n:govoplan-campaign.message_preview.58de1450",
subject,
bodyMode = "text",
text,
@@ -51,12 +51,12 @@ export default function CampaignMessagePreviewOverlay({
metaItems = [],
attachments = [],
raw,
rawLabel = "Raw MIME",
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
navigation,
closeLabel = "Close",
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
onClose
}: CampaignMessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
useEffect(() => {
@@ -95,23 +95,23 @@ export default function CampaignMessagePreviewOverlay({
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
{(recipientLabel || recipientNote || navigation) && (
<div className="template-preview-toolbar">
{(recipientLabel || recipientNote || navigation) &&
<div className="template-preview-toolbar">
<div>
{recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
</div>
{navigation && (
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
{navigation &&
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
)}
}
</div>
)}
}
<MessageDisplayPanel
title={shownSubject}
@@ -121,20 +121,20 @@ export default function CampaignMessagePreviewOverlay({
preferredBodyMode={bodyMode}
deriveTextFromHtml={false}
attachments={attachments}
emptyText="No message content is available."
/>
emptyText="i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6" />
{raw && (
<details className="message-preview-raw">
{raw &&
<details className="message-preview-raw">
<summary>{rawLabel}</summary>
<pre className="mock-message-raw">{raw}</pre>
</details>
)}
}
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
</div>
</div>
);
</div>);
}
function isEditableTarget(target: EventTarget | null): boolean {
@@ -146,4 +146,4 @@ function isEditableTarget(target: EventTarget | null): boolean {
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;

View File

@@ -9,14 +9,14 @@ import {
removePlaceholderFromText,
replacePlaceholderInText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
type UndefinedPlaceholder } from
"../utils/templatePlaceholders";
import {
TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList,
type TemplateFieldOption
} from "./TemplatePlaceholderControls";
type TemplateFieldOption } from
"./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({
open,
@@ -26,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
globalFields,
placeholder,
description,
saveLabel = "Save",
saveLabel = "i18n:govoplan-campaign.save.efc007a3",
validate,
onCancel,
onSave,
onAddField,
canAddField = () => true
}: {
open: boolean;
title: string;
value: string;
localFields: TemplateFieldOption[];
globalFields: TemplateFieldOption[];
placeholder?: string;
description?: string;
saveLabel?: string;
validate?: (value: string) => string;
onCancel: () => void;
onSave: (value: string) => void;
onAddField: (name: string) => void;
canAddField?: (name: string) => boolean;
}) {
}: {open: boolean;title: string;value: string;localFields: TemplateFieldOption[];globalFields: TemplateFieldOption[];placeholder?: string;description?: string;saveLabel?: string;validate?: (value: string) => string;onCancel: () => void;onSave: (value: string) => void;onAddField: (name: string) => void;canAddField?: (name: string) => boolean;}) {
const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -113,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
<Dialog
open={open && !undefinedDialog}
title={title}
closeLabel="Cancel filename changes"
closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
className="template-expression-dialog"
headerClassName="template-expression-dialog-header"
bodyClassName="template-expression-dialog-body"
footerClassName="template-expression-dialog-footer"
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel}>Cancel</Button>
footer={
<>
<Button onClick={onCancel}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
</>
)}
>
}>
{description && <p className="muted template-expression-description">{description}</p>}
<label className="template-expression-value-field">
<span>Archive filename</span>
<span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
<input
ref={inputRef}
value={draftValue}
@@ -140,47 +140,47 @@ export default function TemplateExpressionEditorDialog({
event.preventDefault();
requestSave();
}
}}
/>
}} />
</label>
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
<h3 className="section-mini-heading">Recipient fields</h3>
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
<h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
<p className="muted small-note">i18n:govoplan-campaign.select_a_field_to_insert_it_at_the_current_curso.1d1b51be <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_another_single_value.ce57d76a</p>
<TemplateFieldChipList
namespace="local"
fields={localFields}
usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Campaign fields</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFields}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Used, but undefined</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_but_undefined.3b829043</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="archive filename"
removeLabel="Remove from filename"
contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onReplace={replaceUndefined}
onAddField={addUndefined}
localFields={localFields}
globalFields={globalFields}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
</>,
document.body
);
}
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
@@ -16,14 +16,14 @@ export function TemplateFieldChipList({
empty,
disabled = false,
onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
}: {namespace: TemplateNamespace;fields: TemplateFieldOption[];usedPlaceholders?: TemplatePlaceholder[];empty: string;disabled?: boolean;onInsert: (namespace: TemplateNamespace, name: string) => void;}) {
if (fields.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
@@ -35,46 +35,46 @@ export function TemplateFieldChipList({
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
onClick={() => onInsert(namespace, field.name)}
>
onClick={() => onInsert(namespace, field.name)}>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button>
);
</button>);
})}
</div>
);
</div>);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) {
}: {items: UndefinedPlaceholder[];empty?: string;onSelect: (item: UndefinedPlaceholder) => void;}) {
if (items.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}
>
{items.map((item) =>
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}>
{item.display}
</button>
))}
</div>
);
)}
</div>);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
localFields = [],
globalFields = [],
onCancel,
@@ -82,23 +82,23 @@ export function UndefinedPlaceholderDecisionDialog({
onReplace,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
localFields?: TemplateFieldOption[];
globalFields?: TemplateFieldOption[];
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
}: {field: UndefinedPlaceholder | null;contextLabel?: string;removeLabel?: string;localFields?: TemplateFieldOption[];globalFields?: TemplateFieldOption[];onCancel: () => void;onRemove: (field: UndefinedPlaceholder) => void;onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;onAddField: (field: UndefinedPlaceholder) => void;addDisabled?: boolean;}) {
const replacementOptions = useMemo(
() => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
],
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))],
[globalFields, localFields]
);
const [replacement, setReplacement] = useState("");
@@ -121,42 +121,42 @@ export function UndefinedPlaceholderDecisionDialog({
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
<>
<Button onClick={onCancel}>Continue editing</Button>
footer={field ?
<>
<Button onClick={onCancel}>i18n:govoplan-campaign.continue_editing.b101465f</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
>
{field && (
<>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{field.reason === "invalid-namespace" && (
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)}
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
{onReplace && replacementOptions.length > 0 && (
<label className="template-replacement-field">
<span>Replace by existing field</span>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>i18n:govoplan-campaign.replace.a7cf7b25</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>i18n:govoplan-campaign.add_field.039c6315</Button>
</> :
undefined}>
{field &&
<>
<p>i18n:govoplan-campaign.the.93ef0dd8 {contextLabel} uses <code>{i18nMessage("i18n:govoplan-campaign.value.91317e63", { value0: field.raw })}</code>i18n:govoplan-campaign.but_it_cannot_be_matched_to_a_known_field.e75c4dde</p>
{field.reason === "invalid-namespace" &&
<DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
}
{field.reason === "missing-field" &&
<p className="muted">i18n:govoplan-campaign.you_can_add.66093419 <strong>{field.name}</strong> i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8</p>
}
{onReplace && replacementOptions.length > 0 &&
<label className="template-replacement-field">
<span>i18n:govoplan-campaign.replace_by_existing_field.ee3dc0eb</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) => (
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{replacementOptions.map((option) =>
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name}
</option>
))}
)}
</select>
</label>
)}
}
</>
)}
</Dialog>
);
}
}
</Dialog>);
}

View File

@@ -1,7 +1,7 @@
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
@@ -13,8 +13,7 @@ type VersionLineProps = {
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const navigate = useGuardedNavigate();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null;
@@ -25,7 +24,7 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
navigate(targetUrl);
}
return (
@@ -33,32 +32,32 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.already_at_first_version.b90fad64"
onOpen={openVersion} />
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
fallbackLabel="i18n:govoplan-campaign.no_older_version.4355dee8"
onOpen={openVersion} />
<span>i18n:govoplan-campaign.version.2da600bf {version ? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: version.version_number }) : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.no_newer_version.b210a41c"
onOpen={openVersion} />
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.already_at_latest_version.7533bd9d"
onOpen={openVersion} />
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
</p>);
}
type VersionArrowProps = {
@@ -76,8 +75,8 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
</span>);
}
return (
@@ -86,11 +85,11 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
aria-label={actionLabel}>
<Icon aria-hidden="true" />
</button>
);
</button>);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
@@ -110,13 +109,13 @@ function getVersionActionLabel(icon: VersionArrowProps["icon"], target: Campaign
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_first_value.ea8f5c5b", { value0: versionLabel });
case "previous":
return `Open previous ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_previous_value.e8d4794b", { value0: versionLabel });
case "next":
return `Open next ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_next_value.1315037e", { value0: versionLabel });
case "latest":
return `Open latest ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_latest_value.40bdb320", { value0: versionLabel });
}
}