mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
i18n(policies): route policy UI strings through i18n (English only) (#6628)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e88d22d2fc
commit
cc1235bbf2
@@ -5865,6 +5865,106 @@ deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies.catalog]
|
||||
compliance = "Compliance"
|
||||
ingestion = "Ingestion"
|
||||
retention = "Retention"
|
||||
routing = "Routing"
|
||||
security = "Security"
|
||||
|
||||
[policies.detail]
|
||||
editSettings = "Edit Settings"
|
||||
enforces = "Enforces"
|
||||
managedByOrg = "Managed by your organization. Contact an admin to change this policy."
|
||||
noActivityDescription = "Documents will appear here once this policy runs."
|
||||
noActivityTitle = "No activity yet"
|
||||
onEveryUpload = "On every upload"
|
||||
originalsNote = "Originals stay untouched • Enforced version saved alongside"
|
||||
pause = "Pause"
|
||||
recentActivity = "Recent Activity"
|
||||
resume = "Resume"
|
||||
retry = "Retry"
|
||||
showLess = "Show less"
|
||||
showMore = "Show more"
|
||||
statActive = "Active"
|
||||
statDataProcessed = "Data processed"
|
||||
statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.pii]
|
||||
fieldLabel = "PII to redact"
|
||||
placeholder = "Select PII types"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
infoAriaLabel = "What is a policy?"
|
||||
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
loading = "Loading…"
|
||||
railAriaLabel = "{{label}} policy — {{status}}"
|
||||
railSuffixActive = " (Active)"
|
||||
railSuffixPaused = " (Paused)"
|
||||
setUp = "Set up"
|
||||
title = "Policies"
|
||||
upgradeToEnterprise = "Upgrade to enterprise"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
paused = "Paused"
|
||||
setup = "Set up"
|
||||
|
||||
[policies.toolConfig]
|
||||
enableAriaLabel = "Enable {{tool}}"
|
||||
infoAriaLabel = "What does {{tool}} do?"
|
||||
|
||||
[policies.wizard]
|
||||
allDocTypesDescription = "Enable the Classification policy to filter by document type."
|
||||
allDocTypesTitle = "All document types"
|
||||
back = "Back"
|
||||
builderDesc = "Build the sequence of tools this policy runs on each document."
|
||||
clear = "Clear"
|
||||
continue = "Continue"
|
||||
docTypesLabel = "Document types"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit {{label}} Policy"
|
||||
enablePolicy = "Enable Policy"
|
||||
filenameAutoNumber = "Auto-number"
|
||||
filenamePositionAria = "Filename position"
|
||||
filenamePrefix = "Prefix"
|
||||
filenameSuffix = "Suffix"
|
||||
filenameTextAria = "Filename text"
|
||||
filenameTextPlaceholder = "Text to add (optional)"
|
||||
lockedDescription = "Contact an admin to change this policy."
|
||||
lockedTitle = "Managed by your organization"
|
||||
maxRetriesLabel = "Max retries"
|
||||
noToolsError = "Add at least one configured tool to the workflow first."
|
||||
outputAsLabel = "Output as"
|
||||
outputFilenameSubhead = "Output filename"
|
||||
outputModeAria = "Output mode"
|
||||
outputNewFile = "New file"
|
||||
outputNewVersion = "New version"
|
||||
outputRetriesLabel = "Output & retries"
|
||||
outputSubhead = "Output"
|
||||
retryDelayAria = "Retry delay minutes"
|
||||
retryDelayLabel = "Retry delay (min)"
|
||||
runOnExport = "Export"
|
||||
runOnLabel = "Run on"
|
||||
runOnSubhead = "Run on"
|
||||
runOnUpload = "Upload"
|
||||
saveChanges = "Save Changes"
|
||||
saveError = "Couldn't save the policy. Please try again."
|
||||
setupClassification = "Set up Classification"
|
||||
setupTitle = "Set up {{label}} Policy"
|
||||
sourcesDesc = "Choose where this policy runs and which document types it applies to."
|
||||
sourcesLabel = "Sources"
|
||||
stepOf = "Step {{step}} of {{total}}"
|
||||
toolChainDesc = "Configure the tools this policy runs on each document."
|
||||
typesSelected = "{{count}} types selected"
|
||||
|
||||
|
||||
[printFile]
|
||||
title = "Print File"
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// The global setup mock returns the i18n KEY (t: (key) => key); these tests
|
||||
// assert the rendered English copy, so override locally to return the default
|
||||
// value passed to t() — mirroring i18next's runtime fallback when a key is
|
||||
// missing. (Interpolated strings are returned raw; no test here asserts them.)
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) =>
|
||||
typeof defaultValue === "string" ? defaultValue : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
I18nextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// usePolicies derives `canConfigure` from app-config; with no AppConfigProvider
|
||||
// here `config` is null, which (tri-state gate) hides the edit affordances. Mock
|
||||
// app-config as a single-user deployment (login off) so the local operator can
|
||||
// configure and the narrative view's Edit/Pause/Delete actions render.
|
||||
vi.mock("@app/contexts/AppConfigContext", async (orig) => ({
|
||||
...(await orig<typeof import("@app/contexts/AppConfigContext")>()),
|
||||
useAppConfig: () => ({ config: { enableLogin: false } }),
|
||||
}));
|
||||
|
||||
// The shared Tooltip (used by the "what is a policy?" info button) pulls in
|
||||
// preferences/sidebar contexts we don't set up here — passthrough it.
|
||||
vi.mock("@app/components/shared/Tooltip", () => ({
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
@@ -75,6 +76,7 @@ export function PoliciesSection({
|
||||
* collapse button), mirroring the back-button + title in a policy. */
|
||||
leadingControl?: ReactNode;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
// Persist the expand/collapse state across refreshes.
|
||||
@@ -109,21 +111,26 @@ export function PoliciesSection({
|
||||
<div className="pol-list-head">
|
||||
{leadingControl}
|
||||
<SectionHeader
|
||||
title="Policies"
|
||||
count={`${configuredCount} active`}
|
||||
title={t("policies.sidebar.title", "Policies")}
|
||||
count={t("policies.sidebar.activeCount", "{{count}} active", {
|
||||
count: configuredCount,
|
||||
})}
|
||||
collapsible
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
<AppTooltip
|
||||
content="A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-info-btn"
|
||||
aria-label="What is a policy?"
|
||||
aria-label={t("policies.sidebar.infoAriaLabel", "What is a policy?")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
@@ -149,10 +156,15 @@ export function PoliciesSection({
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">{cat.label}</span>
|
||||
<span className="pol-row-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
<span className="pol-row-soon">
|
||||
Upgrade to enterprise
|
||||
{t(
|
||||
"policies.sidebar.upgradeToEnterprise",
|
||||
"Upgrade to enterprise",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -169,16 +181,20 @@ export function PoliciesSection({
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">{cat.label}</span>
|
||||
<span className="pol-row-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
{status === "setup" ? (
|
||||
<span className="pol-row-setup">Set up</span>
|
||||
<span className="pol-row-setup">
|
||||
{t("policies.sidebar.setUp", "Set up")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={status === "active" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
{t(`policies.status.${status}`, STATUS_LABEL[status])}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
@@ -201,6 +217,7 @@ export function PoliciesSection({
|
||||
* which replaces the Tools area while a policy is selected.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories, configs, sources, docTypes } = usePolicyCatalog();
|
||||
const { selectedId, detailView } = usePolicySelection();
|
||||
@@ -311,7 +328,9 @@ export function PolicyDetailTakeover() {
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">Loading…</p>
|
||||
<p className="pol-desc">
|
||||
{t("policies.sidebar.loading", "Loading…")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -404,6 +423,7 @@ export function PoliciesCollapsedButton({
|
||||
}: {
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
|
||||
@@ -416,16 +436,21 @@ export function PoliciesCollapsedButton({
|
||||
.filter((cat) => !cat.comingSoon)
|
||||
.map((cat) => {
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const label = t(`policies.catalog.${cat.id}`, cat.label);
|
||||
const statusLabel = t(
|
||||
`policies.status.${status}`,
|
||||
STATUS_LABEL[status],
|
||||
);
|
||||
const suffix =
|
||||
status === "active"
|
||||
? " (Active)"
|
||||
? t("policies.sidebar.railSuffixActive", " (Active)")
|
||||
: status === "paused"
|
||||
? " (Paused)"
|
||||
? t("policies.sidebar.railSuffixPaused", " (Paused)")
|
||||
: "";
|
||||
return (
|
||||
<AppTooltip
|
||||
key={cat.id}
|
||||
content={`${cat.label}${suffix}`}
|
||||
content={`${label}${suffix}`}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
@@ -434,7 +459,11 @@ export function PoliciesCollapsedButton({
|
||||
type="button"
|
||||
className="pol-crail-btn"
|
||||
data-status={status}
|
||||
aria-label={`${cat.label} policy — ${STATUS_LABEL[status]}`}
|
||||
aria-label={t(
|
||||
"policies.sidebar.railAriaLabel",
|
||||
"{{label}} policy — {{status}}",
|
||||
{ label, status: statusLabel },
|
||||
)}
|
||||
onClick={() => {
|
||||
selectPolicy(cat.id);
|
||||
onExpand();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
@@ -66,7 +67,13 @@ function humanizeOperation(op: string): string {
|
||||
* lengthy is clamped and collapsed by default with a Show more/less toggle.
|
||||
* Short messages (e.g. "Enforcement failed") render plainly with no toggle.
|
||||
*/
|
||||
function ActivityError({ message }: { message: string }) {
|
||||
function ActivityError({
|
||||
message,
|
||||
t,
|
||||
}: {
|
||||
message: string;
|
||||
t: (key: string, defaultValue: string) => string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const needsToggle = message.length > 80 || message.includes("\n");
|
||||
if (!needsToggle) return <>{message}</>;
|
||||
@@ -82,7 +89,9 @@ function ActivityError({ message }: { message: string }) {
|
||||
className="pol-activity-error__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
{expanded
|
||||
? t("policies.detail.showLess", "Show less")
|
||||
: t("policies.detail.showMore", "Show more")}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
@@ -104,6 +113,7 @@ export function PolicyDetailPanel({
|
||||
onDelete,
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPaused = status === "paused";
|
||||
// Real configured steps drive the flow; fall back to the preset's rule labels.
|
||||
const enforceItems =
|
||||
@@ -123,7 +133,7 @@ export function PolicyDetailPanel({
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
title={category.label}
|
||||
title={t(`policies.catalog.${category.id}`, category.label)}
|
||||
onBack={onBack}
|
||||
actions={
|
||||
<StatusBadge
|
||||
@@ -131,7 +141,9 @@ export function PolicyDetailPanel({
|
||||
showDot
|
||||
pulse={!isPaused}
|
||||
>
|
||||
{isPaused ? "Paused" : "Active"}
|
||||
{isPaused
|
||||
? t("policies.detail.statusPaused", "Paused")
|
||||
: t("policies.detail.statusActive", "Active")}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
@@ -139,7 +151,9 @@ export function PolicyDetailPanel({
|
||||
<div className="pol-scroll">
|
||||
{/* Enforces */}
|
||||
<div>
|
||||
<p className="pol-section-label">Enforces</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.detail.enforces", "Enforces")}
|
||||
</p>
|
||||
<Card padding="default">
|
||||
<div className="pol-rule-flow">
|
||||
<ChipFlow items={enforceItems} separator="arrow" />
|
||||
@@ -151,19 +165,24 @@ export function PolicyDetailPanel({
|
||||
</span>
|
||||
<span className="pol-meta-item">
|
||||
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
|
||||
On every upload
|
||||
{t("policies.detail.onEveryUpload", "On every upload")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-note">
|
||||
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
|
||||
Originals stay untouched • Enforced version saved alongside
|
||||
{t(
|
||||
"policies.detail.originalsNote",
|
||||
"Originals stay untouched • Enforced version saved alongside",
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<p className="pol-section-label">Recent Activity</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</p>
|
||||
{activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activityItems.map((item, i) => (
|
||||
@@ -192,7 +211,7 @@ export function PolicyDetailPanel({
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
@@ -205,7 +224,7 @@ export function PolicyDetailPanel({
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
Retry
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
@@ -217,8 +236,11 @@ export function PolicyDetailPanel({
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title="No activity yet"
|
||||
description="Documents will appear here once this policy runs."
|
||||
title={t("policies.detail.noActivityTitle", "No activity yet")}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@@ -232,15 +254,21 @@ export function PolicyDetailPanel({
|
||||
<span className="pol-stat-value">
|
||||
{statValues.enforced.toLocaleString()}
|
||||
</span>
|
||||
<span className="pol-stat-label">Docs enforced</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statDocsEnforced", "Docs enforced")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.dataProcessed}</span>
|
||||
<span className="pol-stat-label">Data processed</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statDataProcessed", "Data processed")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.activeFor}</span>
|
||||
<span className="pol-stat-label">Active</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statActive", "Active")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -249,7 +277,10 @@ export function PolicyDetailPanel({
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
|
||||
description="Managed by your organization. Contact an admin to change this policy."
|
||||
description={t(
|
||||
"policies.detail.managedByOrg",
|
||||
"Managed by your organization. Contact an admin to change this policy.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -265,14 +296,16 @@ export function PolicyDetailPanel({
|
||||
onClick={onDelete}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
Delete
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onTogglePause}>
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
{isPaused
|
||||
? t("policies.detail.resume", "Resume")
|
||||
: t("policies.detail.pause", "Pause")}
|
||||
</Button>
|
||||
<Button variant="gradient" size="sm" onClick={onEditSettings}>
|
||||
Edit Settings
|
||||
{t("policies.detail.editSettings", "Edit Settings")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
import { Select } from "@shared/components/Select";
|
||||
import { Input } from "@shared/components/Input";
|
||||
@@ -25,6 +26,11 @@ export function PolicyFieldRow({
|
||||
onChange,
|
||||
first,
|
||||
}: PolicyFieldRowProps) {
|
||||
const { t } = useTranslation();
|
||||
// Field labels and option labels come from the policy catalog data, so they're
|
||||
// wrapped at the render site with data-keyed ids (English stays the fallback).
|
||||
const fieldLabel = t(`policies.field.${field.key}`, field.label);
|
||||
|
||||
if (field.type === "chips") {
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const toggle = (opt: string) =>
|
||||
@@ -36,8 +42,12 @@ export function PolicyFieldRow({
|
||||
return (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<div className="pol-field-chips-head">
|
||||
<span className="pol-field-label">{field.label}</span>
|
||||
<span className="pol-field-count">{selected.length} selected</span>
|
||||
<span className="pol-field-label">{fieldLabel}</span>
|
||||
<span className="pol-field-count">
|
||||
{t("policies.fields.selectedCount", "{{count}} selected", {
|
||||
count: selected.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-field-chips">
|
||||
{(field.options ?? []).map((opt) => (
|
||||
@@ -47,7 +57,7 @@ export function PolicyFieldRow({
|
||||
size="sm"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
@@ -61,28 +71,31 @@ export function PolicyFieldRow({
|
||||
size="sm"
|
||||
checked={Boolean(value)}
|
||||
onChange={(checked) => onChange(checked)}
|
||||
aria-label={field.label}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o,
|
||||
label: t(`policies.fieldOption.${field.key}.${o}`, o),
|
||||
}))}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<SettingsRow label={field.label} control={control} />
|
||||
<SettingsRow label={fieldLabel} control={control} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MultiSelect } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
|
||||
/** The set of preset regexes — used to separate preset words from custom ones. */
|
||||
@@ -24,6 +25,7 @@ export function PolicyPiiField({
|
||||
onChange,
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
@@ -48,9 +50,14 @@ export function PolicyPiiField({
|
||||
return (
|
||||
<MultiSelect
|
||||
size="sm"
|
||||
label="PII to redact"
|
||||
placeholder="Select PII types"
|
||||
data={PII_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||
label={t("policies.pii.fieldLabel", "PII to redact")}
|
||||
placeholder={t("policies.pii.placeholder", "Select PII types")}
|
||||
data={PII_PRESETS.map((p) => ({
|
||||
value: p.value,
|
||||
// Preset labels are catalog data — keyed by preset value with the
|
||||
// English label as fallback.
|
||||
label: t(`policies.pii.${p.value}`, p.label),
|
||||
}))}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
@@ -98,6 +99,7 @@ export function PolicySetupWizard({
|
||||
onCommitConfig,
|
||||
onSetupClassification,
|
||||
}: PolicySetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = mode === "edit";
|
||||
// Preset (tool-chain) policies render the locked tool config as their Workflow
|
||||
// step instead of the add/remove builder.
|
||||
@@ -158,13 +160,27 @@ export function PolicySetupWizard({
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
onBack={onCancel}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<EmptyState
|
||||
title="Managed by your organization"
|
||||
description="Contact an admin to change this policy."
|
||||
title={t(
|
||||
"policies.wizard.lockedTitle",
|
||||
"Managed by your organization",
|
||||
)}
|
||||
description={t(
|
||||
"policies.wizard.lockedDescription",
|
||||
"Contact an admin to change this policy.",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +228,12 @@ export function PolicySetupWizard({
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Couldn't save the policy. Please try again.");
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.saveError",
|
||||
"Couldn't save the policy. Please try again.",
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -243,7 +264,12 @@ export function PolicySetupWizard({
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Couldn't save the policy. Please try again.");
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.saveError",
|
||||
"Couldn't save the policy. Please try again.",
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -260,7 +286,12 @@ export function PolicySetupWizard({
|
||||
// the user back to the Workflow step to fix it.
|
||||
const handleSaveFailed = () => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Add at least one configured tool to the workflow first.");
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.noToolsError",
|
||||
"Add at least one configured tool to the workflow first.",
|
||||
),
|
||||
);
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
@@ -269,14 +300,25 @@ export function PolicySetupWizard({
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
|
||||
subtitle={`Step ${step} of ${TOTAL_STEPS}`}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
subtitle={t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
|
||||
step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
onBack={back}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("cancel", "Cancel")}
|
||||
onClick={onCancel}
|
||||
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
@@ -302,7 +344,10 @@ export function PolicySetupWizard({
|
||||
{toolChain ? (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Configure the tools this policy runs on each document.
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
@@ -318,7 +363,10 @@ export function PolicySetupWizard({
|
||||
) : (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Build the sequence of tools this policy runs on each document.
|
||||
{t(
|
||||
"policies.wizard.builderDesc",
|
||||
"Build the sequence of tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
<PolicyWorkflowStep
|
||||
automation={seedAutomation}
|
||||
@@ -351,14 +399,18 @@ export function PolicySetupWizard({
|
||||
)}
|
||||
|
||||
{/* Real, working output + retry settings (applied by the engine). */}
|
||||
<p className="pol-section-label">Output & retries</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.outputRetriesLabel", "Output & retries")}
|
||||
</p>
|
||||
<Card padding="none">
|
||||
{/* The editor event the policy runs on: input on upload, or
|
||||
output on export (enforced before the file is exported). */}
|
||||
<div className="pol-subhead">Run on</div>
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.runOnSubhead", "Run on")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label="Run on"
|
||||
label={t("policies.wizard.runOnLabel", "Run on")}
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
@@ -366,19 +418,27 @@ export function PolicySetupWizard({
|
||||
onChange={(e) =>
|
||||
setRunOn(e.target.value as "upload" | "export")
|
||||
}
|
||||
aria-label="Run on"
|
||||
aria-label={t("policies.wizard.runOnLabel", "Run on")}
|
||||
options={[
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "export", label: "Export" },
|
||||
{
|
||||
value: "upload",
|
||||
label: t("policies.wizard.runOnUpload", "Upload"),
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
label: t("policies.wizard.runOnExport", "Export"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-subhead">Output</div>
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.outputSubhead", "Output")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label="Output as"
|
||||
label={t("policies.wizard.outputAsLabel", "Output as")}
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
@@ -397,17 +457,31 @@ export function PolicySetupWizard({
|
||||
setOutputNamePosition("suffix");
|
||||
}
|
||||
}}
|
||||
aria-label="Output mode"
|
||||
aria-label={t(
|
||||
"policies.wizard.outputModeAria",
|
||||
"Output mode",
|
||||
)}
|
||||
options={[
|
||||
{ value: "new_file", label: "New file" },
|
||||
{ value: "new_version", label: "New version" },
|
||||
{
|
||||
value: "new_file",
|
||||
label: t("policies.wizard.outputNewFile", "New file"),
|
||||
},
|
||||
{
|
||||
value: "new_version",
|
||||
label: t(
|
||||
"policies.wizard.outputNewVersion",
|
||||
"New version",
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Output filename: position + custom text together as one row. */}
|
||||
<div className="pol-subhead">Output filename</div>
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.outputFilenameSubhead", "Output filename")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<div className="pol-name-row">
|
||||
<Select
|
||||
@@ -418,13 +492,30 @@ export function PolicySetupWizard({
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
aria-label="Filename position"
|
||||
aria-label={t(
|
||||
"policies.wizard.filenamePositionAria",
|
||||
"Filename position",
|
||||
)}
|
||||
options={[
|
||||
{ value: "prefix", label: "Prefix" },
|
||||
{ value: "suffix", label: "Suffix" },
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("policies.wizard.filenamePrefix", "Prefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("policies.wizard.filenameSuffix", "Suffix"),
|
||||
},
|
||||
// Auto-number only makes sense for separate new files.
|
||||
...(outputMode === "new_file"
|
||||
? [{ value: "auto-number", label: "Auto-number" }]
|
||||
? [
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"policies.wizard.filenameAutoNumber",
|
||||
"Auto-number",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
@@ -435,15 +526,21 @@ export function PolicySetupWizard({
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
placeholder="Text to add (optional)"
|
||||
aria-label="Filename text"
|
||||
placeholder={t(
|
||||
"policies.wizard.filenameTextPlaceholder",
|
||||
"Text to add (optional)",
|
||||
)}
|
||||
aria-label={t(
|
||||
"policies.wizard.filenameTextAria",
|
||||
"Filename text",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Max retries"
|
||||
label={t("policies.wizard.maxRetriesLabel", "Max retries")}
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
@@ -452,14 +549,20 @@ export function PolicySetupWizard({
|
||||
onChange={(e) =>
|
||||
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
aria-label="Max retries"
|
||||
aria-label={t(
|
||||
"policies.wizard.maxRetriesLabel",
|
||||
"Max retries",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Retry delay (min)"
|
||||
label={t(
|
||||
"policies.wizard.retryDelayLabel",
|
||||
"Retry delay (min)",
|
||||
)}
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
@@ -470,7 +573,10 @@ export function PolicySetupWizard({
|
||||
Math.max(0, Number(e.target.value) || 0),
|
||||
)
|
||||
}
|
||||
aria-label="Retry delay minutes"
|
||||
aria-label={t(
|
||||
"policies.wizard.retryDelayAria",
|
||||
"Retry delay minutes",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -484,10 +590,14 @@ export function PolicySetupWizard({
|
||||
{SOURCES_IN_FLOW && step === 3 && (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Choose where this policy runs and which document types it applies
|
||||
to.
|
||||
{t(
|
||||
"policies.wizard.sourcesDesc",
|
||||
"Choose where this policy runs and which document types it applies to.",
|
||||
)}
|
||||
</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.sourcesLabel", "Sources")}
|
||||
</p>
|
||||
<p className="pol-section-label">Sources</p>
|
||||
<Card padding="none">
|
||||
{sourceDefs.map((src, i) => (
|
||||
<div
|
||||
@@ -506,20 +616,31 @@ export function PolicySetupWizard({
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<p className="pol-section-label">Document types</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.docTypesLabel", "Document types")}
|
||||
</p>
|
||||
{!classificationEnabled ? (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
title="All document types"
|
||||
description="Enable the Classification policy to filter by document type."
|
||||
title={t(
|
||||
"policies.wizard.allDocTypesTitle",
|
||||
"All document types",
|
||||
)}
|
||||
description={t(
|
||||
"policies.wizard.allDocTypesDescription",
|
||||
"Enable the Classification policy to filter by document type.",
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSetupClassification}
|
||||
>
|
||||
Set up Classification
|
||||
{t(
|
||||
"policies.wizard.setupClassification",
|
||||
"Set up Classification",
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -528,14 +649,23 @@ export function PolicySetupWizard({
|
||||
<div className="pol-doctypes-head">
|
||||
<span className="pol-field-label">
|
||||
{scopeTypes.length === 0
|
||||
? "All document types"
|
||||
: `${scopeTypes.length} types selected`}
|
||||
? t(
|
||||
"policies.wizard.allDocTypesTitle",
|
||||
"All document types",
|
||||
)
|
||||
: t(
|
||||
"policies.wizard.typesSelected",
|
||||
"{{count}} types selected",
|
||||
{ count: scopeTypes.length },
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
className="pol-link"
|
||||
onClick={() => setScopeNarrow((v) => !v)}
|
||||
>
|
||||
{scopeNarrow ? "Clear" : "Edit"}
|
||||
{scopeNarrow
|
||||
? t("policies.wizard.clear", "Clear")
|
||||
: t("policies.wizard.edit", "Edit")}
|
||||
</button>
|
||||
</div>
|
||||
{scopeNarrow && (
|
||||
@@ -564,7 +694,9 @@ export function PolicySetupWizard({
|
||||
|
||||
<div className="pol-footer">
|
||||
<Button variant="ghost" size="sm" onClick={back}>
|
||||
{step > 1 ? "Back" : "Cancel"}
|
||||
{step > 1
|
||||
? t("policies.wizard.back", "Back")
|
||||
: t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{step < TOTAL_STEPS ? (
|
||||
<Button
|
||||
@@ -573,7 +705,7 @@ export function PolicySetupWizard({
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
|
||||
>
|
||||
Continue
|
||||
{t("policies.wizard.continue", "Continue")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -583,7 +715,9 @@ export function PolicySetupWizard({
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{isEdit ? "Save Changes" : "Enable Policy"}
|
||||
{isEdit
|
||||
? t("policies.wizard.saveChanges", "Save Changes")
|
||||
: t("policies.wizard.enablePolicy", "Enable Policy")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
import { Card } from "@shared/components/Card";
|
||||
@@ -9,13 +10,24 @@ import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkC
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
/** Plain-language, non-technical descriptions shown by each tool's info button. */
|
||||
const TOOL_PLAIN_INFO: Record<string, string> = {
|
||||
redact:
|
||||
/**
|
||||
* Plain-language, non-technical descriptions shown by each tool's info button.
|
||||
* Stored as [i18n key, English default] pairs so they can be resolved with `t`
|
||||
* at render (the map lives at module scope, outside the component).
|
||||
*/
|
||||
const TOOL_PLAIN_INFO: Record<string, readonly [key: string, en: string]> = {
|
||||
redact: [
|
||||
"policies.toolConfig.info.redact",
|
||||
"Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.",
|
||||
sanitize:
|
||||
],
|
||||
sanitize: [
|
||||
"policies.toolConfig.info.sanitize",
|
||||
"Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.",
|
||||
watermark: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
],
|
||||
watermark: [
|
||||
"policies.toolConfig.info.watermark",
|
||||
"Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
],
|
||||
};
|
||||
|
||||
/** One tool in a policy's fixed chain: whether it runs + its configured params. */
|
||||
@@ -51,6 +63,7 @@ export function PolicyToolConfig({
|
||||
onChange,
|
||||
editable = true,
|
||||
}: PolicyToolConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
|
||||
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
|
||||
|
||||
@@ -59,23 +72,27 @@ export function PolicyToolConfig({
|
||||
{tools.map((tool, index) => {
|
||||
const entry = toolRegistry[tool.operation as ToolId];
|
||||
const Settings = entry?.automationSettings ?? null;
|
||||
const toolName = entry?.name ?? tool.operation;
|
||||
const plainInfo = TOOL_PLAIN_INFO[tool.operation];
|
||||
return (
|
||||
<Card key={tool.operation} padding="none">
|
||||
<div className="pol-tool-head">
|
||||
<span className="pol-tool-icon">{entry?.icon}</span>
|
||||
<span className="pol-tool-name">
|
||||
{entry?.name ?? tool.operation}
|
||||
</span>
|
||||
{TOOL_PLAIN_INFO[tool.operation] && (
|
||||
<span className="pol-tool-name">{toolName}</span>
|
||||
{plainInfo && (
|
||||
<AppTooltip
|
||||
content={TOOL_PLAIN_INFO[tool.operation]}
|
||||
content={t(plainInfo[0], plainInfo[1])}
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-info-btn"
|
||||
aria-label={`What does ${entry?.name ?? tool.operation} do?`}
|
||||
aria-label={t(
|
||||
"policies.toolConfig.infoAriaLabel",
|
||||
"What does {{tool}} do?",
|
||||
{ tool: toolName },
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
@@ -91,7 +108,9 @@ export function PolicyToolConfig({
|
||||
checked={tool.enabled}
|
||||
disabled={!editable}
|
||||
onChange={(checked) => patchTool(index, { enabled: checked })}
|
||||
aria-label={`Enable ${entry?.name ?? tool.operation}`}
|
||||
aria-label={t("policies.toolConfig.enableAriaLabel", "Enable {{tool}}", {
|
||||
tool: toolName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{tool.enabled &&
|
||||
|
||||
Reference in New Issue
Block a user