Portal Policies: table layout with colour-matched icons (#6994)

# Description of Changes

Reworks the Policies catalogue from blocky cards into a clean table with
tinted category icons and tone-matched "enforces" chips, so it matches
the styling used elsewhere in the portal.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2816" height="1318" alt="after-policies-light"
src="https://github.com/user-attachments/assets/af7586f7-8d23-47eb-ae2e-90630d09929b"
/>
<img width="2816" height="1438" alt="before-policies-dark"
src="https://github.com/user-attachments/assets/efcc63d9-5f2d-4dca-944d-d470aa689304"
/>
<img width="2816" height="1438" alt="before-policies-light"
src="https://github.com/user-attachments/assets/2398df15-0632-439f-9f23-0a6ddc496083"
/>
<img width="2816" height="1318" alt="after-policies-dark"
src="https://github.com/user-attachments/assets/9a3726ac-1637-4c7c-ad7a-dcdaf141d89d"
/>


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-07-17 07:07:09 +00:00
committed by GitHub
parent d271b8f357
commit bc96eed040
8 changed files with 227 additions and 25 deletions
@@ -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"
@@ -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 }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyCategoryIcon(entry.category.id)}
</span>
<PolicyCategoryBadge category={entry.category} />
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
<span>{t(entry.category.desc)}</span>
@@ -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<TableColumn<CatalogueEntry>[]>(
() => [
{
key: "policy",
header: t("portal.policies.table.policy", "Policy"),
render: (entry) => (
<div className="portal-policies__cell">
<PolicyCategoryBadge category={entry.category} />
<strong className="portal-policies__cell-name">
{t(entry.category.label)}
</strong>
</div>
),
},
{
key: "enforces",
header: t("portal.policies.table.enforces", "Enforces"),
render: (entry) => (
<div className="portal-policies__rulechips">
{entry.config.rules.map((r) => (
<Chip key={r} accent="neutral" size="sm">
{t(r)}
</Chip>
))}
</div>
),
},
{
key: "scope",
header: t("portal.policies.table.appliesTo", "Applies to"),
render: (entry) => (
<span className="portal-policies__muted">
{t(entry.config.scopeLabel)}
</span>
),
},
{
key: "docs",
header: t("portal.policies.table.docs", "Docs enforced"),
align: "right",
width: "8rem",
render: (entry) => (
<span className="portal-policies__docs">
{entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"}
</span>
),
},
{
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 (
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
);
}
if (isLocked?.(entry)) {
return (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
);
}
if (entry.policy) {
const paused = entry.policy.state.status === "paused";
return (
<StatusBadge
tone={paused ? "warning" : "success"}
size="sm"
pulse={!paused}
>
{paused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
</StatusBadge>
);
}
return (
<Button size="sm" variant="secondary" onClick={() => onOpen(entry)}>
{t("portal.policySummary.action.setUp")}
</Button>
);
},
},
],
[t, onOpen, isLocked, lockedLabel],
);
return (
<Table<CatalogueEntry>
className="portal-policies__table"
columns={columns}
rows={entries}
rowKey={(e) => e.category.id}
onRowClick={(entry) =>
entry.category.comingSoon || isLocked?.(entry)
? undefined
: onOpen(entry)
}
/>
);
}
@@ -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;
}
@@ -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 (
<span className="pcat-badge" aria-hidden>
{policyCategoryIcon(category.id)}
</span>
);
}
@@ -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={
<span className="portal-policies__wizard-title">
<span className="portal-policies__cat-icon" aria-hidden>
{policyCategoryIcon(category.id)}
</span>
<PolicyCategoryBadge category={category} />
{isEdit
? t("portal.policies.wizard.title.edit", {
category: t(category.label),
@@ -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. */
+12 -17
View File
@@ -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 && (
<div className="portal-policies__grid">
{displayCatalogue.map((entry) => (
<PolicyCategoryCard
key={entry.category.id}
entry={entry}
onOpen={openEntry}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
<PolicyCatalogueTable
entries={displayCatalogue}
onOpen={openEntry}
isLocked={isLocked}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
)}
<PolicyDetailPanel