diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4bb5ae928b..eada1744c4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7515,6 +7515,13 @@ label = "Docs enforced" description = "Configured but not firing" label = "Paused" +[portal.policies.table] +appliesTo = "Applies to" +docs = "Docs enforced" +enforces = "Enforces" +policy = "Policy" +status = "Status" + [portal.policies.wizard.actions] back = "Back" cancel = "Cancel" diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx index 9be6ff49cf..9979783536 100644 --- a/frontend/editor/src/portal/components/PolicySummary.tsx +++ b/frontend/editor/src/portal/components/PolicySummary.tsx @@ -15,7 +15,7 @@ import { type CatalogueEntry, type PoliciesResponse, } from "@portal/api/policies"; -import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon"; import "@portal/components/PolicySummary.css"; /** @@ -63,9 +63,7 @@ export function PolicySummary() { header: t("portal.policySummary.column.policy"), render: ({ entry }) => (
- - {policyCategoryIcon(entry.category.id)} - +
{t(entry.category.label)} {t(entry.category.desc)} diff --git a/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx b/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx new file mode 100644 index 0000000000..6986f66f74 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui"; +import type { CatalogueEntry } from "@portal/api/policies"; +import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon"; +import "@portal/views/Policies.css"; + +interface PolicyCatalogueTableProps { + entries: CatalogueEntry[]; + onOpen: (entry: CatalogueEntry) => void; + /** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */ + isLocked?: (entry: CatalogueEntry) => boolean; + /** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */ + lockedLabel?: string; +} + +/** + * The policy catalogue as a proper data table (Policy / Enforces / Applies to / + * Docs / Status), replacing the stacked full-width cards that read as "blocky". + * Same shared Table + StatusBadge + Chip primitives the Sources, Documents and + * Home policy tables use, so every list page in the portal now reads alike. + */ +export function PolicyCatalogueTable({ + entries, + onOpen, + isLocked, + lockedLabel, +}: PolicyCatalogueTableProps) { + const { t } = useTranslation(); + + const columns = useMemo[]>( + () => [ + { + key: "policy", + header: t("portal.policies.table.policy", "Policy"), + render: (entry) => ( +
+ + + {t(entry.category.label)} + +
+ ), + }, + { + key: "enforces", + header: t("portal.policies.table.enforces", "Enforces"), + render: (entry) => ( +
+ {entry.config.rules.map((r) => ( + + {t(r)} + + ))} +
+ ), + }, + { + key: "scope", + header: t("portal.policies.table.appliesTo", "Applies to"), + render: (entry) => ( + + {t(entry.config.scopeLabel)} + + ), + }, + { + key: "docs", + header: t("portal.policies.table.docs", "Docs enforced"), + align: "right", + width: "8rem", + render: (entry) => ( + + {entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"} + + ), + }, + { + key: "status", + header: t("portal.policies.table.status", "Status"), + align: "right", + width: "8.5rem", + render: (entry) => { + if (entry.category.comingSoon) { + // One consistent neutral chip for every "Upgrade to Enterprise" — + // the same action should read the same on every row. + return ( + + {t("portal.policies.card.comingSoon")} + + ); + } + if (isLocked?.(entry)) { + return ( + + {lockedLabel ?? t("portal.policies.card.requiresAiEngine")} + + ); + } + if (entry.policy) { + const paused = entry.policy.state.status === "paused"; + return ( + + {paused + ? t("portal.policies.status.paused") + : t("portal.policies.status.active")} + + ); + } + return ( + + ); + }, + }, + ], + [t, onOpen, isLocked, lockedLabel], + ); + + return ( + + className="portal-policies__table" + columns={columns} + rows={entries} + rowKey={(e) => e.category.id} + onRowClick={(entry) => + entry.category.comingSoon || isLocked?.(entry) + ? undefined + : onOpen(entry) + } + /> + ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css new file mode 100644 index 0000000000..96113b7aa1 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css @@ -0,0 +1,15 @@ +/* Neutral gray rounded icon badge for a policy category. Lives with the + component so every consumer gets the styling without importing a view CSS. */ +.pcat-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + border-radius: var(--radius-lg); + background: var(--color-bg-muted); + color: var(--color-text-3); + /* The shared MUI outline glyph inherits its size from here. */ + font-size: 1.15rem; +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx new file mode 100644 index 0000000000..89243711c9 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx @@ -0,0 +1,16 @@ +import type { PolicyCategory } from "@portal/api/policies"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import "@portal/components/policies/PolicyCategoryIcon.css"; + +/** A neutral gray rounded badge holding the category's shared outline icon. */ +export function PolicyCategoryBadge({ + category, +}: { + category: PolicyCategory; +}) { + return ( + + {policyCategoryIcon(category.id)} + + ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 632e3f610c..582332518c 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -34,7 +34,7 @@ import { import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; -import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon"; import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection"; @@ -334,9 +334,7 @@ function PolicySetupWizardBody({ width="lg" title={ - - {policyCategoryIcon(category.id)} - + {isEdit ? t("portal.policies.wizard.title.edit", { category: t(category.label), diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index 021e48d76c..c6095dabc1 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -453,3 +453,38 @@ .portal-policies__detail-stats .sui-stat + .sui-stat { border-left: 1px solid var(--color-border-light); } + +/* ── Catalogue table (replaces the stacked category cards) ──────────────── */ +.portal-policies__cell { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.portal-policies__cell-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); + min-width: 0; +} + +.portal-policies__rulechips { + display: flex; + flex-wrap: wrap; + gap: 0.3125rem; +} + +.portal-policies__muted { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-policies__docs { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} +/* The .pcat-badge icon styles live with the PolicyCategoryIcon component + (PolicyCategoryIcon.css) so the Home policy summary picks them up too. */ diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 79440f96e7..2de9122e0e 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -18,7 +18,7 @@ import { type PolicySetupResult, } from "@portal/api/policies"; import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary"; -import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard"; +import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable"; import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; @@ -53,14 +53,14 @@ export function Policies() { const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); - function isLocked(entry: CatalogueEntry): boolean { - return ( + const isLocked = useCallback( + (entry: CatalogueEntry): boolean => entry.category.requiresAiEngine === true && !aiEngineEnabled && !aiEngineLoading && - !entry.policy - ); - } + !entry.policy, + [aiEngineEnabled, aiEngineLoading], + ); const catalogue = data?.catalogue ?? []; const refetch = useCallback(() => setVersion((v) => v + 1), []); @@ -184,17 +184,12 @@ export function Policies() { )} {!isLoading && !fetchError && ( -
- {displayCatalogue.map((entry) => ( - - ))} -
+ )}