mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Convert Processor tables to be consistent with each other (#7312)
# Description of Changes Currently, the tables in the processor are inconsistently styled and function differently to each other because they don't all share the same table component. This is a big problem since the Processor is a largely table-based UI and most of most pages in it are tables. This PR creates a new common `DataTable` component and converts all existing tables in the Processor (other than those in the docs pages) to use it, tweaking the individual tables content as necessary so they all work the same. The `DataTable` component is designed to take minimal JSX as arguments. Instead, it takes typed data from the caller and it converts that into JSX, which makes it a lot easier to guarantee that the tables will all render the same and if we want to update the table styling in the future, we just need to do it in one place. I also added sorting capabilities to many of the tables since it was easy with the library and it was appropriate for many of the existing table columns. ## Table Comparisons <img width="2296" height="861" alt="AuditTab-comparison" src="https://github.com/user-attachments/assets/92473508-3f93-45c4-9485-ee21d0d1ecd0" /> <img width="2296" height="617" alt="DocumentExtractions-comparison" src="https://github.com/user-attachments/assets/cf194210-f4c9-42d1-bce1-5a70c0f8fdc5" /> <img width="2296" height="369" alt="InstanceHealthTable-comparison" src="https://github.com/user-attachments/assets/4b048c62-4237-4043-ae89-047df0cd7e60" /> <img width="2296" height="2304" alt="Integrations-comparison" src="https://github.com/user-attachments/assets/9d9edead-8da0-4d49-a249-253579cfdb0e" /> <img width="2296" height="521" alt="InvoicesList-comparison" src="https://github.com/user-attachments/assets/2b6d40b7-d63d-404b-a834-79d5fc62ba9d" /> <img width="2296" height="348" alt="LinkedInstancesTable-comparison" src="https://github.com/user-attachments/assets/fb65c660-baa4-4fcd-8893-05be1962fdab" /> <img width="2296" height="356" alt="PendingInvitations-comparison" src="https://github.com/user-attachments/assets/1f0105ed-b1f8-4e4e-998f-57024875a28f" /> <img width="2296" height="306" alt="PipelinesTable-comparison" src="https://github.com/user-attachments/assets/d84255bd-37f3-4245-b2b5-ef8c06c000ae" /> <img width="2296" height="652" alt="PolicyCatalogue-comparison" src="https://github.com/user-attachments/assets/55f25f20-46f0-47c0-aa40-516cbd5acab5" /> <img width="2296" height="1221" alt="ReviewQueueTable-comparison" src="https://github.com/user-attachments/assets/2206a50c-731f-48cc-958f-aad5e30e47b0" /> <img width="2296" height="344" alt="SourcesTable-comparison" src="https://github.com/user-attachments/assets/76b54efb-54ea-42a8-a0b1-b0cb21f73296" /> <img width="2296" height="659" alt="UsersDirectory-comparison" src="https://github.com/user-attachments/assets/524d1b63-efcf-4ac8-a07d-11aa1849c189" />
This commit is contained in:
@@ -3010,6 +3010,7 @@ saturation = "Saturation and brightness"
|
||||
title = "Choose color"
|
||||
|
||||
[common]
|
||||
actions = "Actions"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
close = "Close"
|
||||
@@ -3025,6 +3026,7 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
open = "Open"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -6339,7 +6341,6 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -7262,12 +7263,11 @@ editorAction = "Editor"
|
||||
empty = "No documents match this filter."
|
||||
rowActions = "Row actions"
|
||||
sensitiveLabel = "Sensitive"
|
||||
sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
action = "Pipeline / Action"
|
||||
actions = "Actions"
|
||||
document = "Document"
|
||||
labels = "Labels"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
time = "Time"
|
||||
@@ -7283,6 +7283,7 @@ host = "Host"
|
||||
lastSeen = "Last seen"
|
||||
region = "Region"
|
||||
status = "Status"
|
||||
target = "Target"
|
||||
version = "Version"
|
||||
|
||||
[portal.editorAdmin.health.empty]
|
||||
@@ -7602,11 +7603,9 @@ security = "Security"
|
||||
storage = "Storage"
|
||||
|
||||
[portal.integrations]
|
||||
addAnother = "Add another"
|
||||
availableHeading = "Available"
|
||||
comingSoonHeading = "Coming soon"
|
||||
connect = "Connect"
|
||||
connectedHeading = "Connected"
|
||||
connectionCount_one = "{{count}} connection"
|
||||
connectionCount_other = "{{count}} connections"
|
||||
customApi = "Custom API"
|
||||
@@ -7624,6 +7623,10 @@ security = "Security"
|
||||
signing = "Signing"
|
||||
storage = "Storage"
|
||||
|
||||
[portal.integrations.noResults]
|
||||
description = "No integrations match your filters. Try a different category or search."
|
||||
title = "No matches"
|
||||
|
||||
[portal.integrations.status]
|
||||
connected = "Connected"
|
||||
|
||||
@@ -7826,10 +7829,10 @@ paused = "Paused"
|
||||
|
||||
[portal.pipelines.table]
|
||||
name = "Pipeline"
|
||||
open = "Open"
|
||||
sources = "Sources"
|
||||
status = "Status"
|
||||
steps = "Steps"
|
||||
trigger = "Trigger"
|
||||
|
||||
[portal.pipelines.trigger]
|
||||
folder-watch = "Folder watch"
|
||||
@@ -8722,9 +8725,9 @@ unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
open = "Open"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
type = "Type"
|
||||
usedBy = "Policies"
|
||||
|
||||
[portal.sources.types.box]
|
||||
@@ -10948,6 +10951,13 @@ approver = "Approves policy"
|
||||
editor = "Editor"
|
||||
processor = "Processor"
|
||||
|
||||
[users.columns]
|
||||
capabilities = "Capabilities"
|
||||
email = "Email"
|
||||
person = "Person"
|
||||
role = "Role"
|
||||
status = "Status"
|
||||
|
||||
[users.confirm]
|
||||
cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link."
|
||||
cancelInviteTitle = "Cancel invitation"
|
||||
@@ -10967,10 +10977,8 @@ title = "No members yet"
|
||||
addToTeam = "Add to team"
|
||||
guestCount = "{{count}} guest"
|
||||
guests = "Guests"
|
||||
guestsDesc = "External collaborators, scoped to what you shared. Editor only."
|
||||
ledBy = "led by {{owner}}"
|
||||
org = "Organization"
|
||||
orgDesc = "Owners with org-wide authority and policy approval"
|
||||
owners = "{{count}} owner"
|
||||
team = "{{name}} team"
|
||||
teamMeta = "{{count}} people"
|
||||
@@ -11010,13 +11018,15 @@ usernamePlaceholder = "jsmith"
|
||||
[users.invites]
|
||||
by = "Invited by {{who}}"
|
||||
cancel = "Cancel"
|
||||
count = "{{count}} pending"
|
||||
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
|
||||
expiresInDays_one = "Expires in {{count}} day"
|
||||
expiresInDays_other = "Expires in {{count}} days"
|
||||
expiresToday = "Expires today"
|
||||
title = "Pending invitations"
|
||||
|
||||
[users.invites.columns]
|
||||
expires = "Expires"
|
||||
invitee = "Invitee"
|
||||
|
||||
[users.loadError]
|
||||
description = "Something went wrong reaching the backend, or you don't have access. Try again."
|
||||
title = "Couldn't load members"
|
||||
|
||||
@@ -32,6 +32,9 @@ html[data-app-theme="light"] {
|
||||
--c-primary: var(--p-blue-500);
|
||||
--c-primary-hover: var(--p-blue-600);
|
||||
--c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent);
|
||||
/* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on
|
||||
light surfaces, where --c-primary itself does not. */
|
||||
--c-accent-text: var(--p-blue-700);
|
||||
|
||||
--c-success: var(--p-green-600);
|
||||
--c-danger: var(--p-red-600);
|
||||
@@ -144,6 +147,8 @@ html[data-app-theme="midnight"] {
|
||||
--c-text-muted: var(--p-zinc-200);
|
||||
--c-text-subtle: var(--p-zinc-250);
|
||||
--c-text-on-primary: var(--p-white);
|
||||
/* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */
|
||||
--c-accent-text: var(--p-blue-400);
|
||||
--c-btn-solid: var(--c-text);
|
||||
--c-btn-inverse: var(--p-ink);
|
||||
--c-btn-secondary: var(--p-c-1a1a1d);
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
/* ── Z-index ladder ── */
|
||||
--z-dropdown: 25;
|
||||
--z-drawer: 50;
|
||||
--z-popover: 150;
|
||||
--z-toast: 200;
|
||||
/* Fullscreen tool-picker surfaces (editor) */
|
||||
--z-fullscreen-icon-svg: 1;
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* DataTable: the one Stirling table. Appearance is owned entirely here: the
|
||||
* canonical surface, the header/row grammar, the standardized loading / empty /
|
||||
* error states, and every cell KIND (`sui-dtc__*`). Call-sites choose a kind and
|
||||
* supply data; they never style a cell. This is what lets every table look and
|
||||
* behave the same.
|
||||
*/
|
||||
|
||||
.sui-datatable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The one canonical surface: every table wears it; call-sites don't wrap it. */
|
||||
.sui-datatable__frame {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sui-datatable__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.sui-datatable__scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sui-datatable__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sui-datatable__caption {
|
||||
caption-side: top;
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.sui-datatable__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-datatable__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-datatable__th--fit {
|
||||
width: 1%;
|
||||
}
|
||||
/* Accessible-only header text for blank affordance/action columns. */
|
||||
.sui-datatable__th-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sui-datatable__sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.sui-datatable__th--right .sui-datatable__sort {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.sui-datatable__sort:hover {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-datatable__sort:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: 0.125rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.sui-datatable__sort-icon {
|
||||
display: inline-flex;
|
||||
color: var(--c-text-subtle);
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.sui-datatable__sort:hover .sui-datatable__sort-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sui-datatable__sort-icon--asc,
|
||||
.sui-datatable__sort-icon--desc {
|
||||
opacity: 1;
|
||||
color: var(--c-primary);
|
||||
}
|
||||
.sui-datatable__sort-icon--desc svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.sui-datatable__td {
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sui-datatable__table tbody tr:last-child .sui-datatable__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.sui-datatable__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-datatable__td--nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-datatable__td--fit {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Density (the compact variant) */
|
||||
.sui-datatable--compact .sui-datatable__th,
|
||||
.sui-datatable--compact .sui-datatable__td {
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
/* Interactive rows */
|
||||
.sui-datatable__row--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-datatable__row--interactive:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
.sui-datatable__row--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
/* Non-actionable rows (e.g. coming-soon): de-emphasized to read as disabled.
|
||||
Muting is done with accessible colours + a faded (non-text) icon rather than
|
||||
row opacity, which would blend text under the WCAG contrast floor. */
|
||||
.sui-datatable__row--muted .sui-dtc__entity-name {
|
||||
color: var(--c-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-datatable__row--muted .sui-dtc__entity-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sui-datatable__chevron {
|
||||
display: inline-flex;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* Full-width state cell (empty / error) */
|
||||
.sui-datatable__state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-datatable__state--error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.sui-datatable__state--node {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cell kinds (sui-dtc__*): the locked cell vocabulary. These absorb the
|
||||
* per-namespace portal-*__ cell styles (mono / muted / cell-stack / name-cell /
|
||||
* caret …) that every table used to re-implement.
|
||||
*/
|
||||
.sui-dtc__text {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
/* Labeled value ("Category: action"): full-strength text, bold label. */
|
||||
.sui-dtc__text--labeled {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-dtc__text-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sui-dtc__mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
.sui-dtc__muted {
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-dtc__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sui-dtc__labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* entity: leading icon + bold name + optional muted note */
|
||||
.sui-dtc__entity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-dtc__entity-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
/* The component sizes bare icons; self-styled marks (Avatar) render as-is. */
|
||||
.sui-dtc__entity-icon svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
.sui-dtc__entity-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-dtc__entity-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sui-dtc__entity-name {
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-dtc__entity-suffix {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-dtc__note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.sui-dtc__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sui-dtc__menu-item--danger {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* Grouped tables: section header row + per-group "show all" toggle. */
|
||||
.sui-datatable__group-cell {
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--c-surface-sunken, var(--c-hover));
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.sui-datatable__table
|
||||
tbody
|
||||
tr.sui-datatable__group:not(:first-child)
|
||||
.sui-datatable__group-cell {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
.sui-datatable__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.sui-datatable__group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-datatable__group-title strong {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-datatable__group-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-datatable__group-more {
|
||||
padding: 0.5rem 0.875rem;
|
||||
}
|
||||
.sui-datatable__show-all {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.sui-datatable__show-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sui-dtc__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-dtc__progress-bar {
|
||||
display: block;
|
||||
width: 5rem;
|
||||
}
|
||||
.sui-dtc__progress-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.sui-dtc__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
/* Neutral chip-style link (matches the invoice-link look): the colour is not an
|
||||
* accent - the trailing glyph + hover fill carry the affordance. */
|
||||
.sui-dtc__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
}
|
||||
.sui-dtc__link:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
.sui-dtc__link:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.sui-dtc__select {
|
||||
min-width: 13rem;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui/DataTable";
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: "healthy" | "degraded";
|
||||
docs: number;
|
||||
latency: number;
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "US East",
|
||||
code: "us-east-1",
|
||||
status: "healthy",
|
||||
docs: 12481,
|
||||
latency: 41,
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "US West",
|
||||
code: "us-west-2",
|
||||
status: "healthy",
|
||||
docs: 8210,
|
||||
latency: 63,
|
||||
auto: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "EU West",
|
||||
code: "eu-west-1",
|
||||
status: "degraded",
|
||||
docs: 3044,
|
||||
latency: 190,
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "AP South",
|
||||
code: "ap-south-1",
|
||||
status: "healthy",
|
||||
docs: 5622,
|
||||
latency: 88,
|
||||
auto: false,
|
||||
},
|
||||
];
|
||||
|
||||
const tone = (r: Region) => ({
|
||||
tone: r.status === "healthy" ? ("success" as const) : ("warning" as const),
|
||||
label: r.status,
|
||||
});
|
||||
|
||||
const COLUMNS: DataTableColumn<Region>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: "Region",
|
||||
primary: (r) => r.name,
|
||||
}),
|
||||
column.mono({ key: "code", header: "Code", get: (r) => r.code }),
|
||||
column.badge({ key: "status", header: "Status", get: tone }),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
get: (r) => r.docs,
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: "P95",
|
||||
get: (r) => r.latency,
|
||||
format: (n) => `${n} ms`,
|
||||
}),
|
||||
];
|
||||
|
||||
const SORTABLE_COLUMNS: DataTableColumn<Region>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: "Region",
|
||||
primary: (r) => r.name,
|
||||
sortable: true,
|
||||
}),
|
||||
column.mono({
|
||||
key: "code",
|
||||
header: "Code",
|
||||
get: (r) => r.code,
|
||||
sortable: true,
|
||||
}),
|
||||
column.badge({ key: "status", header: "Status", get: tone, sortable: true }),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
get: (r) => r.docs,
|
||||
format: (n) => n.toLocaleString(),
|
||||
sortable: true,
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: "P95",
|
||||
get: (r) => r.latency,
|
||||
format: (n) => `${n} ms`,
|
||||
sortable: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "Compound/DataTable",
|
||||
component: DataTable,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
/** Columns come from the `column` vocabulary; call-sites never style a cell. */
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Sorting is opt-in per column (`sortable: true`); click a header. */
|
||||
export const Sortable: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={SORTABLE_COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
defaultSort={{ key: "docs", direction: "desc" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** `onRowClick` + a chevron affordance. The line above shows the click land. */
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [clicked, setClicked] = useState<Region | null>(null);
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
fontSize: "0.8125rem",
|
||||
color: "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
{clicked
|
||||
? `Clicked: ${clicked.name} (${clicked.code})`
|
||||
: "Click a row to fire onRowClick."}
|
||||
</p>
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={setClicked}
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
/** A trailing action column (icon-only kebab), locked to the design system. */
|
||||
export const WithActions: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
column.actions({
|
||||
key: "actions",
|
||||
get: () => [
|
||||
{
|
||||
label: "Row actions",
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
onClick: () => {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** First-load skeleton mirrors the real column layout. */
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
loading
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Standardized empty slot. */
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No regions deployed yet."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Standardized error slot (announced as an alert). */
|
||||
export const ErrorState: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
error="Couldn't load regions. Try again."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Optional toolbar slot above the table, inside the surface. */
|
||||
export const WithToolbar: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
toolbar={
|
||||
<>
|
||||
<strong style={{ fontSize: "0.8125rem" }}>Regions</strong>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="secondary" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** The one look choice: the `compact` variant. */
|
||||
export const Compact: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
variant="compact"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,516 @@
|
||||
import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
createColumnHelper,
|
||||
createSortedRowModel,
|
||||
flexRender,
|
||||
type RowData,
|
||||
rowSortingFeature,
|
||||
sortFn_alphanumeric,
|
||||
sortFn_basic,
|
||||
type SortingState,
|
||||
tableFeatures,
|
||||
useTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Skeleton } from "@app/ui/Skeleton";
|
||||
import {
|
||||
type CellAction,
|
||||
type DataTableColumn,
|
||||
renderCellActions,
|
||||
} from "@app/ui/dataTableColumns";
|
||||
import "@app/ui/DataTable.css";
|
||||
|
||||
export * from "@app/ui/dataTableColumns";
|
||||
|
||||
/** Per-column presentation carried through TanStack's typed `meta` slot. */
|
||||
interface ColumnMeta {
|
||||
align: "left" | "right";
|
||||
nowrap: boolean;
|
||||
fit: boolean;
|
||||
/** Visually-hidden header text for blank affordance/action columns, so the
|
||||
* column still has an accessible name (avoids axe `empty-table-header`). */
|
||||
srHeader?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature registry for every DataTable, built once. Sorting is always
|
||||
* registered so any column can opt in; the core row model defaults in.
|
||||
*/
|
||||
const DATA_TABLE_FEATURES = tableFeatures({
|
||||
rowSortingFeature,
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
columnMeta: {} as ColumnMeta,
|
||||
// Register the comparators the column vocabulary uses. Without this v9 falls
|
||||
// back to a case-sensitive `basic` sort and warns per column.
|
||||
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
|
||||
});
|
||||
type DataTableFeatures = typeof DATA_TABLE_FEATURES;
|
||||
|
||||
/** Closed appearance dial — the only look choice a call-site may make. */
|
||||
export type DataTableVariant = "default" | "compact";
|
||||
|
||||
/**
|
||||
* A collapsible section of rows under a locked header. Group headers are
|
||||
* structured (title + muted meta + optional right-aligned actions), never raw
|
||||
* markup, so grouped tables stay as opinionated as flat ones. Provide `groups`
|
||||
* instead of `rows`.
|
||||
*/
|
||||
export interface DataTableGroup<T> {
|
||||
key: string;
|
||||
title: string;
|
||||
/** Muted sub-text on the header (e.g. "5 people · led by Dana"). */
|
||||
meta?: string;
|
||||
/** Right-aligned header actions (e.g. "Add to team", a kebab menu). */
|
||||
actions?: CellAction[];
|
||||
rows: T[];
|
||||
/** Collapse rows past this count behind a "Show all N" toggle. */
|
||||
collapseAfter?: number;
|
||||
/** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
/** Columns built with the `column` vocabulary — never raw JSX. */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Flat rows. Provide this OR `groups`, not both. */
|
||||
rows?: T[];
|
||||
/** Grouped rows with section headers. Takes precedence over `rows`. */
|
||||
groups?: DataTableGroup<T>[];
|
||||
rowKey: (row: T) => string;
|
||||
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Per-row interactivity gate, checked only when `onRowClick` is set. */
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/** Trailing affordance drawn on interactive rows. */
|
||||
rowAffordance?: "none" | "chevron";
|
||||
|
||||
/** Initial sort, applied to the matching sortable column. */
|
||||
defaultSort?: { key: string; direction?: "asc" | "desc" };
|
||||
|
||||
/** First-load state: renders column-shaped skeleton rows under the header. */
|
||||
loading?: boolean;
|
||||
/** Skeleton row count while loading. Defaults to 6. */
|
||||
skeletonRows?: number;
|
||||
/** Error slot — replaces the rows with an alert message row. */
|
||||
error?: ReactNode;
|
||||
/** Shown when there are no rows (and not loading / no error). Text or a node. */
|
||||
empty?: ReactNode;
|
||||
|
||||
/** Content above the table (filters, search, actions), inside the surface. */
|
||||
toolbar?: ReactNode;
|
||||
/** The only appearance choice. */
|
||||
variant?: DataTableVariant;
|
||||
/** Accessible caption for the table. */
|
||||
caption?: string;
|
||||
/** Labels for a group's "show all / show less" toggle (pass translated).
|
||||
* `showAll` receives the group's total row count. */
|
||||
collapseLabels?: { showAll: (total: number) => string; showLess: string };
|
||||
}
|
||||
|
||||
function ChevronGlyph() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="m9 6 6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 8 6 15h12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const CHEVRON_COLUMN_KEY = "__affordance";
|
||||
|
||||
/**
|
||||
* The shared Stirling table. Call-sites supply data + behaviour; the component
|
||||
* owns 100% of the appearance. Columns come from the `column` vocabulary (typed
|
||||
* cell kinds, no raw markup), the surface / density / states are standardized
|
||||
* here, and the only look choice exposed is the closed `variant`. Behaviour -
|
||||
* sorting today, more later - is opt-in per column or via props.
|
||||
*/
|
||||
export function DataTable<T extends RowData>({
|
||||
columns,
|
||||
rows = [],
|
||||
groups,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
rowAffordance = "none",
|
||||
defaultSort,
|
||||
loading = false,
|
||||
skeletonRows = 6,
|
||||
error,
|
||||
empty,
|
||||
toolbar,
|
||||
variant = "default",
|
||||
caption,
|
||||
collapseLabels = {
|
||||
showAll: (n) => `Show all ${n}`,
|
||||
showLess: "Show less",
|
||||
},
|
||||
}: DataTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort
|
||||
? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }]
|
||||
: [],
|
||||
);
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||
const toggleGroup = (key: string) =>
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// The data source is either grouped or flat; TanStack (headers, sorting for
|
||||
// the flat path) is fed the flattened rows.
|
||||
const flatRows = useMemo(
|
||||
() => (groups ? groups.flatMap((g) => g.rows) : rows),
|
||||
[groups, rows],
|
||||
);
|
||||
|
||||
const interactive = Boolean(onRowClick);
|
||||
const showChevron = interactive && rowAffordance === "chevron";
|
||||
// A row that holds its own controls (actions/links/select/caps) can't also be
|
||||
// a `role="button"` (a button may not contain interactive descendants); it
|
||||
// keeps the click as a mouse shortcut, and the inner control is the keyboard path.
|
||||
const rowsContainControls = columns.some((c) => c.interactive);
|
||||
|
||||
const effectiveColumns = useMemo<DataTableColumn<T>[]>(() => {
|
||||
if (!showChevron) return columns;
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: CHEVRON_COLUMN_KEY,
|
||||
header: "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
renderCell: (row) =>
|
||||
(isRowInteractive?.(row) ?? true) ? (
|
||||
<span className="sui-datatable__chevron" aria-hidden>
|
||||
<ChevronGlyph />
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}, [columns, showChevron, isRowInteractive]);
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<DataTableFeatures, T>[]>(() => {
|
||||
const helper = createColumnHelper<DataTableFeatures, T>();
|
||||
return effectiveColumns.map((c) => {
|
||||
// A blank header (trailing affordance/action columns) still needs an
|
||||
// accessible name for assistive tech.
|
||||
const srHeader = c.header
|
||||
? undefined
|
||||
: c.key === CHEVRON_COLUMN_KEY
|
||||
? t("common.open", "Open")
|
||||
: t("common.actions", "Actions");
|
||||
const meta: ColumnMeta = {
|
||||
align: c.align,
|
||||
nowrap: c.nowrap,
|
||||
fit: c.fit,
|
||||
srHeader,
|
||||
};
|
||||
if (c.sortable && c.sortValue) {
|
||||
const sortValue = c.sortValue;
|
||||
return helper.accessor((row: T): unknown => sortValue(row), {
|
||||
id: c.key,
|
||||
header: () => c.header,
|
||||
cell: (ctx) => c.renderCell(ctx.row.original),
|
||||
enableSorting: true,
|
||||
sortUndefined: "last",
|
||||
sortFn: c.sortFn ?? "basic",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
return helper.display({
|
||||
id: c.key,
|
||||
header: () => c.header,
|
||||
cell: (ctx) => c.renderCell(ctx.row.original),
|
||||
meta,
|
||||
});
|
||||
});
|
||||
}, [effectiveColumns, t]);
|
||||
|
||||
const table = useTable({
|
||||
features: DATA_TABLE_FEATURES,
|
||||
data: flatRows,
|
||||
columns: tanstackColumns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => rowKey(row),
|
||||
});
|
||||
|
||||
const colCount = effectiveColumns.length;
|
||||
|
||||
// Shared row wiring so grouped rows behave like flat ones (interactivity +
|
||||
// the affordance column) instead of being a second-class path.
|
||||
const rowProps = (original: T, muted?: boolean) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(original) ?? true);
|
||||
// A row that owns the whole interaction takes the button role + keyboard
|
||||
// handling; a row with its own controls keeps just the mouse click.
|
||||
const asButton = rowInteractive && !rowsContainControls;
|
||||
return {
|
||||
className: [
|
||||
"sui-datatable__row",
|
||||
rowInteractive ? "sui-datatable__row--interactive" : "",
|
||||
muted ? "sui-datatable__row--muted" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
onClick: rowInteractive ? () => onRowClick?.(original) : undefined,
|
||||
tabIndex: asButton ? 0 : undefined,
|
||||
role: asButton ? ("button" as const) : undefined,
|
||||
onKeyDown: asButton
|
||||
? (e: KeyboardEvent<HTMLTableRowElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(original);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// No rows at all - covers a grouped table whose groups are all empty (or an
|
||||
// empty groups list), which would otherwise render a header-only table.
|
||||
const noRows = groups
|
||||
? groups.every((g) => g.rows.length === 0)
|
||||
: rows.length === 0;
|
||||
|
||||
let body: ReactNode;
|
||||
if (loading) {
|
||||
body = Array.from({ length: skeletonRows }).map((_, r) => (
|
||||
<tr key={`skeleton-${r}`} className="sui-datatable__row">
|
||||
{effectiveColumns.map((c) => (
|
||||
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
|
||||
<Skeleton
|
||||
height="0.75rem"
|
||||
width={c.align === "right" || c.fit ? "40%" : "70%"}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
} else if (error != null) {
|
||||
body = (
|
||||
<tr>
|
||||
<td
|
||||
className="sui-datatable__state sui-datatable__state--error"
|
||||
colSpan={colCount}
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else if (noRows) {
|
||||
const isNode = typeof empty === "object" && empty !== null;
|
||||
body = (
|
||||
<tr>
|
||||
<td
|
||||
className={
|
||||
isNode
|
||||
? "sui-datatable__state sui-datatable__state--node"
|
||||
: "sui-datatable__state"
|
||||
}
|
||||
colSpan={colCount}
|
||||
>
|
||||
{empty ?? "No data"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else if (groups) {
|
||||
body = groups.flatMap((g) => {
|
||||
const limit = g.collapseAfter ?? Infinity;
|
||||
const open = openGroups.has(g.key);
|
||||
const overflow = g.rows.length > limit;
|
||||
const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows;
|
||||
const header = (
|
||||
<tr key={`group-${g.key}`} className="sui-datatable__group">
|
||||
<td colSpan={colCount} className="sui-datatable__group-cell">
|
||||
<div className="sui-datatable__group-head">
|
||||
<div className="sui-datatable__group-title">
|
||||
<strong>{g.title}</strong>
|
||||
{g.meta && (
|
||||
<span className="sui-datatable__group-meta">{g.meta}</span>
|
||||
)}
|
||||
</div>
|
||||
{g.actions &&
|
||||
g.actions.length > 0 &&
|
||||
renderCellActions(g.actions)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const rowEls = shown.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
data-row-key={rowKey(row)}
|
||||
{...rowProps(row, g.muted)}
|
||||
>
|
||||
{effectiveColumns.map((c) => (
|
||||
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
|
||||
{c.renderCell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
const moreEl = overflow ? (
|
||||
<tr key={`more-${g.key}`}>
|
||||
<td colSpan={colCount} className="sui-datatable__group-more">
|
||||
<button
|
||||
type="button"
|
||||
className="sui-datatable__show-all"
|
||||
onClick={() => toggleGroup(g.key)}
|
||||
>
|
||||
{open
|
||||
? collapseLabels.showLess
|
||||
: collapseLabels.showAll(g.rows.length)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null;
|
||||
return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls];
|
||||
});
|
||||
} else {
|
||||
body = table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} data-row-key={row.id} {...rowProps(row.original)}>
|
||||
{row.getAllCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta;
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cellClass(
|
||||
meta?.align ?? "left",
|
||||
meta?.nowrap ?? false,
|
||||
meta?.fit ?? false,
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`sui-datatable sui-datatable--${variant}`}>
|
||||
<div className="sui-datatable__frame">
|
||||
{toolbar && <div className="sui-datatable__toolbar">{toolbar}</div>}
|
||||
<div className="sui-datatable__scroll">
|
||||
<table className="sui-datatable__table">
|
||||
{caption && (
|
||||
<caption className="sui-datatable__caption">{caption}</caption>
|
||||
)}
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta;
|
||||
const align = meta?.align ?? "left";
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
const label = header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
scope="col"
|
||||
className={headerClass(align, meta?.fit ?? false)}
|
||||
aria-sort={
|
||||
canSort
|
||||
? sorted === "asc"
|
||||
? "ascending"
|
||||
: sorted === "desc"
|
||||
? "descending"
|
||||
: "none"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{canSort ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-datatable__sort"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={`sui-datatable__sort-icon sui-datatable__sort-icon--${sorted || "none"}`}
|
||||
>
|
||||
<SortGlyph />
|
||||
</span>
|
||||
</button>
|
||||
) : meta?.srHeader ? (
|
||||
<span className="sui-datatable__th-sr">
|
||||
{meta.srHeader}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>{body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cellClass(
|
||||
align: "left" | "right",
|
||||
nowrap: boolean,
|
||||
fit: boolean,
|
||||
): string {
|
||||
return [
|
||||
"sui-datatable__td",
|
||||
`sui-datatable__td--${align}`,
|
||||
nowrap ? "sui-datatable__td--nowrap" : "",
|
||||
fit ? "sui-datatable__td--fit" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function headerClass(align: "left" | "right", fit: boolean): string {
|
||||
return [
|
||||
"sui-datatable__th",
|
||||
`sui-datatable__th--${align}`,
|
||||
fit ? "sui-datatable__th--fit" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -4,28 +4,20 @@
|
||||
}
|
||||
|
||||
.sui-dd__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
/* Positioned (fixed, portaled to <body>) entirely by the Menu component. */
|
||||
min-width: 12rem;
|
||||
padding: var(--space-1);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
z-index: var(--z-popover);
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.sui-dd__menu--start {
|
||||
left: 0;
|
||||
}
|
||||
.sui-dd__menu--end {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sui-dd__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@app/ui/Dropdown.css";
|
||||
|
||||
type Alignment = "start" | "end";
|
||||
@@ -20,6 +22,8 @@ interface DropdownContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
/** The portaled menu element, so click-outside can exclude it. */
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
menuId: string;
|
||||
align: Alignment;
|
||||
}
|
||||
@@ -68,15 +72,20 @@ function Root({
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuId = useId();
|
||||
|
||||
// Click-outside + Escape close.
|
||||
// Click-outside + Escape close. The menu is portaled to <body>, so it is not
|
||||
// inside containerRef - check it separately or a click on it would close the
|
||||
// menu before the item's handler runs.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
!containerRef.current.contains(target) &&
|
||||
!(menuRef.current && menuRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -96,7 +105,7 @@ function Root({
|
||||
}, [open, setOpen]);
|
||||
|
||||
const value = useMemo<DropdownContextValue>(
|
||||
() => ({ open, setOpen, triggerRef, menuId, align }),
|
||||
() => ({ open, setOpen, triggerRef, menuRef, menuId, align }),
|
||||
[open, setOpen, menuId, align],
|
||||
);
|
||||
|
||||
@@ -150,23 +159,79 @@ export interface DropdownMenuProps {
|
||||
}
|
||||
|
||||
function Menu({ children, className, width }: DropdownMenuProps) {
|
||||
const { open, menuId, align } = useDropdownCtx();
|
||||
if (!open) return null;
|
||||
const style =
|
||||
width !== undefined
|
||||
const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx();
|
||||
// Fixed position tracked to the trigger. Portaling to <body> keeps the menu
|
||||
// out of any `overflow` ancestor (e.g. a table's horizontal scroll area),
|
||||
// which would otherwise clip it and add a scrollbar.
|
||||
const [pos, setPos] = useState<{
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const margin = 8;
|
||||
const spaceBelow = window.innerHeight - r.bottom - margin;
|
||||
const spaceAbove = r.top - margin;
|
||||
// Flip above when there's more room there, so a trigger near the viewport
|
||||
// bottom doesn't open a fixed menu that runs off-screen and can't scroll.
|
||||
const below = spaceBelow >= spaceAbove;
|
||||
const horizontal =
|
||||
align === "end"
|
||||
? { right: window.innerWidth - r.right }
|
||||
: { left: r.left };
|
||||
setPos({
|
||||
...horizontal,
|
||||
...(below
|
||||
? { top: r.bottom + gap }
|
||||
: { bottom: window.innerHeight - r.top + gap }),
|
||||
maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap),
|
||||
});
|
||||
};
|
||||
place();
|
||||
// Track the trigger while scrolling/resizing (capture catches inner scrollers).
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open, align, triggerRef]);
|
||||
|
||||
if (!open || !pos) return null;
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
// Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak
|
||||
// in on the axis this placement isn't pinning.
|
||||
top: pos.top ?? "auto",
|
||||
bottom: pos.bottom ?? "auto",
|
||||
left: pos.left ?? "auto",
|
||||
right: pos.right ?? "auto",
|
||||
maxHeight: pos.maxHeight,
|
||||
overflowY: "auto",
|
||||
...(width !== undefined
|
||||
? { minWidth: typeof width === "number" ? `${width}px` : width }
|
||||
: undefined;
|
||||
return (
|
||||
: {}),
|
||||
};
|
||||
return createPortal(
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className={["sui-dd__menu", `sui-dd__menu--${align}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
ref={menuRef}
|
||||
className={["sui-dd__menu", className ?? ""].filter(Boolean).join(" ")}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
.sui-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Header text kept for assistive tech only, so a column of controls can be named
|
||||
without putting a heading above it. Defined here rather than borrowing a global
|
||||
utility, since the portal loads its own stylesheet. */
|
||||
.sui-table__th-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sui-table__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-table__th--right,
|
||||
.sui-table__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-table__th--center,
|
||||
.sui-table__td--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sui-table__td {
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sui-table tbody tr:last-child .sui-table__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sui-table__row--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-table__row--interactive:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
.sui-table__row--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
.sui-table__empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Table, type TableColumn } from "@app/ui/Table";
|
||||
import { StatusBadge } from "@app/ui/StatusBadge";
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: "healthy" | "degraded";
|
||||
docs: number;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "US East",
|
||||
code: "us-east-1",
|
||||
status: "healthy",
|
||||
docs: 12481,
|
||||
latency: "41 ms",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "US West",
|
||||
code: "us-west-2",
|
||||
status: "healthy",
|
||||
docs: 8210,
|
||||
latency: "63 ms",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "EU West",
|
||||
code: "eu-west-1",
|
||||
status: "degraded",
|
||||
docs: 3044,
|
||||
latency: "190 ms",
|
||||
},
|
||||
];
|
||||
|
||||
const COLUMNS: TableColumn<Region>[] = [
|
||||
{ key: "name", header: "Region", render: (r) => r.name },
|
||||
{
|
||||
key: "code",
|
||||
header: "Code",
|
||||
render: (r) => (
|
||||
<code style={{ fontFamily: "var(--font-mono)" }}>{r.code}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={r.status === "healthy" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{r.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
align: "right",
|
||||
render: (r) => r.docs.toLocaleString(),
|
||||
},
|
||||
{ key: "latency", header: "P95", align: "right", render: (r) => r.latency },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: "Compound/Table",
|
||||
component: Table,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Table<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
|
||||
),
|
||||
};
|
||||
|
||||
/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Empty body slot. */
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No regions deployed yet."
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@app/ui/Surface.css";
|
||||
import "@app/ui/Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
/** Stable column id. */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
/**
|
||||
* Hides the header visually but keeps it for assistive tech. For a trailing column of controls
|
||||
* or chevrons, where a visible heading would be noise but a blank one leaves the cells below it
|
||||
* unlabelled.
|
||||
*/
|
||||
headerHidden?: boolean;
|
||||
/** Cell renderer for a row. */
|
||||
render: (row: T) => ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional fixed/min width (any CSS length). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
rows: T[];
|
||||
/** Stable key per row. */
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/**
|
||||
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
|
||||
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
|
||||
* all rows interactive.
|
||||
*/
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/**
|
||||
* Set when rows render controls of their own. The row keeps its click as a mouse shortcut but
|
||||
* stops announcing itself as a button, because a button may not contain other controls and a
|
||||
* {@code <tr role="button">} is no longer a row to a screen reader. That row control is then the
|
||||
* keyboard path to the same action, so nothing is lost by leaving the row itself inert.
|
||||
*/
|
||||
rowsContainControls?: boolean;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal data table primitive. Columns own their own cell renderers, so the
|
||||
* table stays presentational — callers pre-sort/filter and pass the rows they
|
||||
* want shown. Rows become focusable buttons-in-disguise when `onRowClick` is
|
||||
* set.
|
||||
*/
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
rowsContainControls = false,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const interactive = Boolean(onRowClick);
|
||||
return (
|
||||
<div
|
||||
className={["sui-surface", "sui-table-wrap", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<table className="sui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={`sui-table__th sui-table__th--${c.align ?? "left"}`}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
>
|
||||
{c.headerHidden ? (
|
||||
<span className="sui-table__th-sr">{c.header}</span>
|
||||
) : (
|
||||
c.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="sui-table__empty" colSpan={columns.length}>
|
||||
{empty ?? "No data"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(row) ?? true);
|
||||
// Only a row that owns the whole interaction takes the button role and the keyboard
|
||||
// handling that goes with it; see rowsContainControls.
|
||||
const rowIsControl = rowInteractive && !rowsContainControls;
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
rowInteractive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
|
||||
tabIndex={rowIsControl ? 0 : undefined}
|
||||
role={rowIsControl ? "button" : undefined}
|
||||
onKeyDown={
|
||||
rowIsControl
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge";
|
||||
import { Chip, type ChipAccent } from "@app/ui/Chip";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Dropdown } from "@app/ui/Dropdown";
|
||||
import { ProgressBar } from "@app/ui/ProgressBar";
|
||||
import { Select, type SelectOption } from "@app/ui/Select";
|
||||
|
||||
/**
|
||||
* The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and
|
||||
* supply the data + semantics; the component owns 100% of the appearance. There
|
||||
* is no raw-JSX / className escape hatch by design; a cell can only look the way
|
||||
* the design system draws its kind, so every table looks and behaves the same.
|
||||
*/
|
||||
|
||||
type Align = "left" | "right";
|
||||
type SortValue = string | number | boolean | null | undefined;
|
||||
|
||||
/**
|
||||
* Which built-in comparator sorts a column. Set by the builder from the cell's
|
||||
* data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for
|
||||
* text, `basic` (raw numeric) for numbers. Call-sites never choose this.
|
||||
*/
|
||||
export type DataTableSortFn = "alphanumeric" | "basic";
|
||||
|
||||
/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */
|
||||
export interface DataTableColumn<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
align: Align;
|
||||
/** Prevent wrapping (mono/number values). */
|
||||
nowrap: boolean;
|
||||
/** Shrink the column to its content (actions / affordances). */
|
||||
fit: boolean;
|
||||
sortable: boolean;
|
||||
sortValue?: (row: T) => SortValue;
|
||||
/** Comparator kind, derived from the cell type. Only set when sortable. */
|
||||
sortFn?: DataTableSortFn;
|
||||
/** Cell renders its own interactive control (button/link/select/chip). Rows
|
||||
* containing one drop their `role="button"` so a button never nests inside a
|
||||
* button - the control is the keyboard path instead. */
|
||||
interactive?: boolean;
|
||||
/** Internal, design-system-owned renderer. Call-sites never supply this. */
|
||||
renderCell: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
/** The only design-system glyph a cell may use (icon-only actions). */
|
||||
export type CellGlyph = "kebab";
|
||||
|
||||
function KebabGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="19" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** An item in a kebab action menu. */
|
||||
export interface CellMenuItem {
|
||||
label: string;
|
||||
tone?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
/** Draw a divider above this item. */
|
||||
dividerBefore?: boolean;
|
||||
}
|
||||
|
||||
/** A row/group action. A locked button, or a kebab menu when `menu` is set. */
|
||||
export interface CellAction {
|
||||
label: string;
|
||||
glyph?: CellGlyph;
|
||||
/** Icon-only (uses `label` as the accessible name). */
|
||||
iconOnly?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** When set, the button opens this menu instead of firing `onClick`. */
|
||||
menu?: CellMenuItem[];
|
||||
}
|
||||
|
||||
/** Renders a row of locked action buttons / kebab menus. Shared by the
|
||||
* `actions` cell kind and grouped-table headers. */
|
||||
export function renderCellActions(actions: CellAction[]): ReactNode {
|
||||
return (
|
||||
<div className="sui-dtc__actions" onClick={(e) => e.stopPropagation()}>
|
||||
{actions.map((a) =>
|
||||
a.menu ? (
|
||||
<Dropdown.Root key={a.label}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant={a.iconOnly ? "tertiary" : "secondary"}
|
||||
size="sm"
|
||||
shape={a.iconOnly ? "circle" : undefined}
|
||||
leftSection={a.glyph ? <KebabGlyph /> : undefined}
|
||||
loading={a.loading}
|
||||
disabled={a.disabled}
|
||||
aria-label={a.iconOnly ? a.label : undefined}
|
||||
>
|
||||
{a.iconOnly ? undefined : a.label}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width={210}>
|
||||
{a.menu.map((m) => (
|
||||
<Fragment key={m.label}>
|
||||
{m.dividerBefore && <Dropdown.Divider />}
|
||||
<Dropdown.Item
|
||||
onSelect={m.onClick}
|
||||
disabled={m.disabled}
|
||||
className={
|
||||
m.tone === "danger"
|
||||
? "sui-dtc__menu-item--danger"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</Dropdown.Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
) : (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.iconOnly ? "tertiary" : "secondary"}
|
||||
accent={a.tone === "danger" ? "danger" : undefined}
|
||||
size="sm"
|
||||
shape={a.iconOnly ? "circle" : undefined}
|
||||
leftSection={a.glyph ? <KebabGlyph /> : undefined}
|
||||
loading={a.loading}
|
||||
disabled={a.disabled}
|
||||
aria-label={a.iconOnly ? a.label : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
a.onClick?.();
|
||||
}}
|
||||
>
|
||||
{a.iconOnly ? undefined : a.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An external link inside a cell. */
|
||||
export interface CellLink {
|
||||
label: string;
|
||||
href: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface Common {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
function base<T>(
|
||||
o: Common,
|
||||
extra: Pick<DataTableColumn<T>, "align" | "nowrap" | "fit" | "renderCell"> & {
|
||||
sortValue?: (row: T) => SortValue;
|
||||
sortFn?: DataTableSortFn;
|
||||
interactive?: boolean;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header,
|
||||
align: extra.align,
|
||||
nowrap: extra.nowrap,
|
||||
fit: extra.fit,
|
||||
sortable: !!o.sortable,
|
||||
sortValue: o.sortable ? extra.sortValue : undefined,
|
||||
sortFn: o.sortable ? extra.sortFn : undefined,
|
||||
interactive: extra.interactive,
|
||||
renderCell: extra.renderCell,
|
||||
};
|
||||
}
|
||||
|
||||
function text<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => string;
|
||||
/** Optional bold label rendered before the value as "Label: value". */
|
||||
label?: (row: T) => string | null | undefined;
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const label = o.label?.(r);
|
||||
return label ? (
|
||||
<span className="sui-dtc__text sui-dtc__text--labeled">
|
||||
<strong className="sui-dtc__text-label">{label}:</strong> {o.get(r)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="sui-dtc__text">{o.get(r)}</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mono<T>(
|
||||
o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => <code className="sui-dtc__mono">{o.get(r)}</code>,
|
||||
});
|
||||
}
|
||||
|
||||
function muted<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => string | null | undefined;
|
||||
placeholder?: string;
|
||||
/** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => (
|
||||
<span className="sui-dtc__muted">
|
||||
{o.get(r) || (o.placeholder ?? "-")}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function number<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => number | null | undefined;
|
||||
format?: (n: number, row: T) => string;
|
||||
placeholder?: string;
|
||||
/** Override the sort key (e.g. a raw count behind a formatted label). */
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
|
||||
sortFn: "basic",
|
||||
renderCell: (r) => {
|
||||
const n = o.get(r);
|
||||
if (n == null) {
|
||||
return (
|
||||
<span className="sui-dtc__num sui-dtc__muted">
|
||||
{o.placeholder ?? "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="sui-dtc__num">
|
||||
{o.format ? o.format(n, r) : String(n)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function badge<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => { tone: StatusTone; label: string };
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r).label),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const b = o.get(r);
|
||||
return (
|
||||
<StatusBadge tone={b.tone} size="sm">
|
||||
{b.label}
|
||||
</StatusBadge>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-defined label, rendered as a dot-less pill. Use this ONLY for labels
|
||||
* that come from data / the user (e.g. a document's classification). Values from
|
||||
* a fixed set we define (types, environments, providers) are `text`, not pills.
|
||||
*/
|
||||
export interface CellLabel {
|
||||
label: string;
|
||||
accent?: ChipAccent;
|
||||
}
|
||||
|
||||
function labels<T>(
|
||||
o: Common & { get: (row: T) => CellLabel[] },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: (r) => o.get(r)[0]?.label ?? undefined,
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__labels">
|
||||
{o.get(r).map((l) => (
|
||||
<Chip
|
||||
key={l.label}
|
||||
accent={l.accent ?? "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{l.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An interactive capability chip: click to grant, remove to revoke, dashed to
|
||||
* offer adding. A functional cell (it toggles state), distinct from static
|
||||
* `labels`.
|
||||
*/
|
||||
export interface CellCap {
|
||||
label: string;
|
||||
accent?: ChipAccent;
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function caps<T>(
|
||||
o: Common & { get: (row: T) => CellCap[] },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__labels">
|
||||
{o.get(r).map((c) => (
|
||||
<Chip
|
||||
key={c.label}
|
||||
accent={c.accent ?? "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
dashed={c.dashed}
|
||||
onClick={c.onClick}
|
||||
onRemove={c.onRemove}
|
||||
>
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function entity<T>(
|
||||
o: Common & {
|
||||
/** Semantic leading icon (component owns its size + colour container). */
|
||||
icon?: (row: T) => ReactNode;
|
||||
primary: (row: T) => string;
|
||||
/** Muted inline suffix after the name, its own node (e.g. "(you)"). */
|
||||
suffix?: (row: T) => string | null | undefined;
|
||||
/** Secondary muted line under the name. */
|
||||
note?: (row: T) => string | null | undefined;
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.primary(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const icon = o.icon?.(r);
|
||||
const suffix = o.suffix?.(r);
|
||||
const note = o.note?.(r);
|
||||
return (
|
||||
<div className="sui-dtc__entity">
|
||||
{icon != null && (
|
||||
<span className="sui-dtc__entity-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="sui-dtc__entity-body">
|
||||
<span className="sui-dtc__entity-head">
|
||||
<span className="sui-dtc__entity-name">{o.primary(r)}</span>
|
||||
{suffix && (
|
||||
<span className="sui-dtc__entity-suffix">{suffix}</span>
|
||||
)}
|
||||
</span>
|
||||
{note && <span className="sui-dtc__note">{note}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function actions<T>(o: {
|
||||
key: string;
|
||||
header?: ReactNode;
|
||||
get: (row: T) => CellAction[];
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header ?? "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => renderCellActions(o.get(r)),
|
||||
};
|
||||
}
|
||||
|
||||
function progress<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => { value: number; label?: string };
|
||||
/** Accessible name for the bar (it has no visible text). Defaults to the
|
||||
* shown percent; pass a description like "Load for us-east-1" when useful. */
|
||||
ariaLabel?: (row: T) => string;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: (r) => o.get(r).value,
|
||||
sortFn: "basic",
|
||||
renderCell: (r) => {
|
||||
const p = o.get(r);
|
||||
const shown = p.label ?? `${Math.round(p.value * 100)}%`;
|
||||
return (
|
||||
<div className="sui-dtc__progress">
|
||||
<span className="sui-dtc__progress-bar">
|
||||
<ProgressBar
|
||||
value={p.value}
|
||||
thresholded
|
||||
height={6}
|
||||
label={o.ariaLabel?.(r) ?? shown}
|
||||
/>
|
||||
</span>
|
||||
<span className="sui-dtc__progress-pct">{shown}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function links<T>(o: {
|
||||
key: string;
|
||||
header?: ReactNode;
|
||||
get: (row: T) => CellLink[];
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header ?? "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__links">
|
||||
{o.get(r).map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
className="sui-dtc__link"
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={l.ariaLabel}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function select<T>(o: {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
get: (row: T) => {
|
||||
value?: string | null;
|
||||
defaultValue?: string;
|
||||
options: SelectOption[];
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
/** Omit for an uncontrolled select (local UI state only). */
|
||||
onChange?: (row: T, value: string | null) => void;
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header,
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => {
|
||||
const s = o.get(r);
|
||||
const change = o.onChange;
|
||||
return (
|
||||
<div className="sui-dtc__select">
|
||||
<Select
|
||||
options={s.options}
|
||||
value={s.value}
|
||||
defaultValue={s.defaultValue}
|
||||
onChange={change ? (v) => change(r, v) : undefined}
|
||||
aria-label={s.ariaLabel}
|
||||
disabled={s.disabled}
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The DataTable column vocabulary. Each builder produces a locked-appearance
|
||||
* column; call-sites choose the kind + supply data, never styling.
|
||||
*/
|
||||
export const column = {
|
||||
text,
|
||||
mono,
|
||||
muted,
|
||||
number,
|
||||
badge,
|
||||
labels,
|
||||
caps,
|
||||
entity,
|
||||
actions,
|
||||
progress,
|
||||
links,
|
||||
select,
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export * from "@app/ui/Collapsible";
|
||||
export * from "@app/ui/Tabs";
|
||||
export * from "@app/ui/Dropdown";
|
||||
export * from "@app/ui/Drawer";
|
||||
export * from "@app/ui/Table";
|
||||
export * from "@app/ui/DataTable";
|
||||
|
||||
// Forms
|
||||
export * from "@app/ui/FormField";
|
||||
|
||||
@@ -95,12 +95,6 @@ export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
|
||||
error: "danger",
|
||||
};
|
||||
|
||||
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
|
||||
API: "brand",
|
||||
Editor: "success",
|
||||
Automation: "warning",
|
||||
};
|
||||
|
||||
/** Classification chip accent: danger when unclassified, warning when it needs a look. */
|
||||
export function classificationTone(doc: ReviewDocument): ChipAccent {
|
||||
if (doc.classification === "Unclassified") return "danger";
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
|
||||
import type { LinkedInstanceRow } from "@portal/api/link";
|
||||
|
||||
interface Props {
|
||||
@@ -45,78 +38,67 @@ export function LinkedInstancesTable({
|
||||
revokingId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const cols: TableColumn<LinkedInstanceRow>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<LinkedInstanceRow>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: t("portal.accountLink.instances.columns.instance", "Instance"),
|
||||
render: (i) => (
|
||||
<div className="portal-link__cell-stack">
|
||||
<span className="portal-link__cell-strong">
|
||||
{i.name ??
|
||||
t("portal.accountLink.instances.unnamed", "Unnamed instance")}
|
||||
</span>
|
||||
<code className="portal-link__device-id">{i.deviceId}</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (i) =>
|
||||
i.name ?? t("portal.accountLink.instances.unnamed", "Unnamed instance"),
|
||||
note: (i) => i.deviceId,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.accountLink.instances.columns.status", "Status"),
|
||||
render: (i) =>
|
||||
i.revoked ? (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{t("portal.accountLink.instances.revoked", "Revoked")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.accountLink.instances.active", "Active")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) =>
|
||||
i.revoked
|
||||
? {
|
||||
tone: "danger",
|
||||
label: t("portal.accountLink.instances.revoked", "Revoked"),
|
||||
}
|
||||
: {
|
||||
tone: "success",
|
||||
label: t("portal.accountLink.instances.active", "Active"),
|
||||
},
|
||||
}),
|
||||
column.muted({
|
||||
key: "lastSeen",
|
||||
header: t("portal.accountLink.instances.columns.lastSeen", "Last seen"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.lastSeenAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// Sort on the real ISO timestamp, not the "3d ago" label.
|
||||
sortBy: (i) => i.lastSeenAt ?? undefined,
|
||||
get: (i) => relativeTime(i.lastSeenAt, t),
|
||||
}),
|
||||
column.muted({
|
||||
key: "created",
|
||||
header: t("portal.accountLink.instances.columns.linked", "Linked"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.createdAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortBy: (i) => i.createdAt ?? undefined,
|
||||
get: (i) => relativeTime(i.createdAt, t),
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
header: (
|
||||
<span className="sr-only">
|
||||
{t("portal.accountLink.instances.columns.actions", "Actions")}
|
||||
</span>
|
||||
),
|
||||
align: "right",
|
||||
render: (i) =>
|
||||
i.revoked ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
loading={revokingId === i.instanceId}
|
||||
onClick={() => onRevoke(i)}
|
||||
>
|
||||
{t("portal.accountLink.instances.revoke", "Revoke")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
get: (i) =>
|
||||
i.revoked
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t("portal.accountLink.instances.revoke", "Revoke"),
|
||||
tone: "danger",
|
||||
loading: revokingId === i.instanceId,
|
||||
onClick: () => onRevoke(i),
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<DataTable<LinkedInstanceRow>
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => String(i.instanceId)}
|
||||
empty={
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t(
|
||||
@@ -128,13 +110,7 @@ export function LinkedInstancesTable({
|
||||
"Link this org's account, then register your self-hosted instances to see them here.",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => String(i.instanceId)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
type CellLink,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { formatMinor, formatPeriodDate } from "@app/billing";
|
||||
import { fetchInvoices, type Invoice } from "@portal/api/billing";
|
||||
@@ -80,96 +81,78 @@ export function InvoicesList() {
|
||||
// Date · Amount · Status · Description (product name) · Actions
|
||||
// The monospace invoice id is dropped — users care about "what was it for",
|
||||
// not the internal id.
|
||||
const columns: TableColumn<Invoice>[] = [
|
||||
{
|
||||
const columns: DataTableColumn<Invoice>[] = [
|
||||
column.text({
|
||||
key: "date",
|
||||
header: t("portal.billing.invoices.columnDate", "Date"),
|
||||
render: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—",
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// Sort chronologically on the raw ISO timestamp, not the formatted label.
|
||||
sortBy: (inv) => inv.createdAt ?? undefined,
|
||||
get: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "-",
|
||||
}),
|
||||
column.number({
|
||||
key: "pdfs",
|
||||
header: t(
|
||||
"portal.billing.invoices.columnPdfsProcessed",
|
||||
"PDFs processed",
|
||||
),
|
||||
align: "right",
|
||||
// Billed units on the invoice's metered line item; "—" when the
|
||||
sortable: true,
|
||||
// Billed units on the invoice's metered line item; blank when the
|
||||
// line-item table isn't synced into the Stripe mirror.
|
||||
render: (inv) =>
|
||||
inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(),
|
||||
},
|
||||
{
|
||||
get: (inv) => inv.pdfsProcessed,
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.number({
|
||||
key: "amount",
|
||||
header: t("portal.billing.invoices.columnAmount", "Amount"),
|
||||
align: "right",
|
||||
render: (inv) =>
|
||||
inv.totalMinor == null
|
||||
? "—"
|
||||
: formatMinor(inv.totalMinor, inv.currency),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) => inv.totalMinor,
|
||||
format: (n, inv) => formatMinor(n, inv.currency),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.billing.invoices.columnStatus", "Status"),
|
||||
render: (inv) => (
|
||||
<StatusBadge tone={statusTone(inv.status)} size="sm">
|
||||
{inv.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) => ({ tone: statusTone(inv.status), label: inv.status }),
|
||||
}),
|
||||
column.text({
|
||||
key: "description",
|
||||
header: t("portal.billing.invoices.columnDescription", "Description"),
|
||||
render: (inv) => (
|
||||
<span className="portal-billing__invoice-desc">
|
||||
{inv.description ??
|
||||
t("portal.billing.invoices.descriptionFallback", "Invoice")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) =>
|
||||
inv.description ??
|
||||
t("portal.billing.invoices.descriptionFallback", "Invoice"),
|
||||
}),
|
||||
column.links({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (inv) => (
|
||||
<div className="portal-billing__invoice-actions">
|
||||
{inv.hostedInvoiceUrl && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.hostedInvoiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t(
|
||||
"portal.billing.invoices.viewAriaLabel",
|
||||
"View invoice {{number}} in Stripe",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.invoices.viewLink", "View ↗")}
|
||||
</a>
|
||||
)}
|
||||
{inv.invoicePdf && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.invoicePdf}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t(
|
||||
"portal.billing.invoices.downloadAriaLabel",
|
||||
"Download invoice {{number}} as PDF",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.invoices.pdfLink", "PDF ↓")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
get: (inv) => {
|
||||
const out: CellLink[] = [];
|
||||
if (inv.hostedInvoiceUrl) {
|
||||
out.push({
|
||||
label: t("portal.billing.invoices.viewLink", "View ↗"),
|
||||
href: inv.hostedInvoiceUrl,
|
||||
ariaLabel: t(
|
||||
"portal.billing.invoices.viewAriaLabel",
|
||||
"View invoice {{number}} in Stripe",
|
||||
{ number: inv.number ?? inv.id },
|
||||
),
|
||||
});
|
||||
}
|
||||
if (inv.invoicePdf) {
|
||||
out.push({
|
||||
label: t("portal.billing.invoices.pdfLink", "PDF ↓"),
|
||||
href: inv.invoicePdf,
|
||||
ariaLabel: t(
|
||||
"portal.billing.invoices.downloadAriaLabel",
|
||||
"Download invoice {{number}} as PDF",
|
||||
{ number: inv.number ?? inv.id },
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -209,11 +192,11 @@ export function InvoicesList() {
|
||||
|
||||
{invoices !== null && invoices.length > 0 && (
|
||||
<>
|
||||
<Table
|
||||
className="portal-billing__flush-table"
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={visibleRows}
|
||||
rowKey={(inv) => inv.id}
|
||||
defaultSort={{ key: "date", direction: "desc" }}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="portal-billing__invoice-footer">
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Extraction } from "@portal/api/documents";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const ALL = documentsFor("enterprise");
|
||||
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
|
||||
const SENSITIVE = ALL.find((d) => d.sensitive)!;
|
||||
|
||||
// The mock documents ship without extractions, so seed a realistic set here -
|
||||
// a spread of confidence levels so the table (and its confidence sort) is
|
||||
// actually reviewable.
|
||||
const EXTRACTIONS: Extraction[] = [
|
||||
{ field: "Counterparty", value: "Acme Services LLC", confidence: 0.98 },
|
||||
{ field: "Effective date", value: "2026-01-14", confidence: 0.94 },
|
||||
{ field: "Contract value", value: "$248,000.00", confidence: 0.87 },
|
||||
{ field: "Governing law", value: "Delaware", confidence: 0.72 },
|
||||
{ field: "Auto-renewal", value: "Yes (12 months)", confidence: 0.55 },
|
||||
];
|
||||
|
||||
const NON_SENSITIVE = {
|
||||
...ALL.find((d) => !d.sensitive)!,
|
||||
extractions: EXTRACTIONS,
|
||||
fieldsExtracted: EXTRACTIONS.length,
|
||||
};
|
||||
const SENSITIVE = {
|
||||
...ALL.find((d) => d.sensitive)!,
|
||||
extractions: EXTRACTIONS,
|
||||
fieldsExtracted: EXTRACTIONS.length,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DocumentExtractions> = {
|
||||
title: "Portal/Documents/DocumentExtractions",
|
||||
@@ -23,7 +44,7 @@ const meta: Meta<typeof DocumentExtractions> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentExtractions>;
|
||||
|
||||
/** No extraction data exists yet - the table shows its empty state. */
|
||||
/** Extracted fields with a mix of confidence levels; click a header to sort. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Sensitive doc with no active grant — content stays masked. */
|
||||
@@ -35,3 +56,10 @@ export const Masked: Story = {
|
||||
export const Unlocked: Story = {
|
||||
args: { doc: SENSITIVE, unlocked: true },
|
||||
};
|
||||
|
||||
/** No extraction data yet — the empty state. */
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
doc: { ...NON_SENSITIVE, extractions: [], fieldsExtracted: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui";
|
||||
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
|
||||
import {
|
||||
confidencePct,
|
||||
@@ -24,32 +24,30 @@ export function DocumentExtractions({
|
||||
}: DocumentExtractionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<Extraction>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<Extraction>[] = [
|
||||
column.text({
|
||||
key: "field",
|
||||
header: t("portal.documents.extractions.columns.field"),
|
||||
render: (e) => <span className="portal-documents__field">{e.field}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.field,
|
||||
}),
|
||||
column.mono({
|
||||
key: "value",
|
||||
header: t("portal.documents.extractions.columns.value"),
|
||||
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
|
||||
},
|
||||
{
|
||||
get: (e) => e.value,
|
||||
}),
|
||||
column.badge({
|
||||
key: "confidence",
|
||||
header: t("portal.documents.extractions.columns.confidence"),
|
||||
align: "right",
|
||||
width: "7rem",
|
||||
render: (e) => (
|
||||
<StatusBadge
|
||||
tone={confidenceTone(e.confidence)}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{confidencePct(e.confidence)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
// Sort on the whole-percent integer, not the "92%" label (keeps decimals
|
||||
// out of the natural-sort comparator).
|
||||
sortBy: (e) => Math.round(e.confidence * 100),
|
||||
get: (e) => ({
|
||||
tone: confidenceTone(e.confidence),
|
||||
label: confidencePct(e.confidence),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (doc.sensitive && !unlocked) {
|
||||
@@ -66,7 +64,7 @@ export function DocumentExtractions({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<Extraction>
|
||||
<DataTable<Extraction>
|
||||
columns={cols}
|
||||
rows={doc.extractions}
|
||||
rowKey={(e) => e.field}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import {
|
||||
type CellLabel,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
} from "@app/ui";
|
||||
import {
|
||||
classificationTone,
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
PRODUCT_CHIP_TONE,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
|
||||
@@ -15,160 +18,105 @@ interface ReviewQueueTableProps {
|
||||
onRowClick: (doc: ReviewDocument) => void;
|
||||
}
|
||||
|
||||
function BoltIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KebabIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="19" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The document stream - one row per document your org has processed. */
|
||||
export function ReviewQueueTable({
|
||||
documents,
|
||||
onRowClick,
|
||||
}: ReviewQueueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<ReviewDocument>[]>(
|
||||
const columns = useMemo<DataTableColumn<ReviewDocument>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "document",
|
||||
header: t("portal.documents.table.columns.document"),
|
||||
render: (d) => (
|
||||
<div className="portal-documents__doc-cell">
|
||||
<div className="portal-documents__doc-head">
|
||||
<span className="portal-documents__name">{d.name}</span>
|
||||
{d.classification && (
|
||||
<Chip accent={classificationTone(d)} size="sm">
|
||||
{d.classification}
|
||||
</Chip>
|
||||
)}
|
||||
{d.auto && (
|
||||
<Chip accent="success" size="sm" leadingIcon={<BoltIcon />}>
|
||||
{t("portal.documents.table.auto")}
|
||||
</Chip>
|
||||
)}
|
||||
{d.sensitive && (
|
||||
// role="img" so the label is allowed and the icon reads as one thing: aria-label
|
||||
// is ignored on a bare span, leaving the padlock silent.
|
||||
<span
|
||||
className="portal-documents__lock"
|
||||
role="img"
|
||||
title={t("portal.documents.table.sensitiveTitle")}
|
||||
aria-label={t("portal.documents.table.sensitiveLabel")}
|
||||
>
|
||||
<LockRounded style={{ fontSize: "0.95rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{d.note && <span className="portal-documents__note">{d.note}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (d) => d.name,
|
||||
note: (d) => d.note,
|
||||
}),
|
||||
column.labels({
|
||||
key: "labels",
|
||||
header: t("portal.documents.table.columns.labels", "Labels"),
|
||||
get: (d) => {
|
||||
const out: CellLabel[] = [];
|
||||
if (d.classification) {
|
||||
out.push({
|
||||
label: d.classification,
|
||||
accent: classificationTone(d),
|
||||
});
|
||||
}
|
||||
if (d.auto) {
|
||||
out.push({
|
||||
label: t("portal.documents.table.auto"),
|
||||
accent: "success",
|
||||
});
|
||||
}
|
||||
if (d.sensitive) {
|
||||
out.push({
|
||||
label: t("portal.documents.table.sensitiveLabel"),
|
||||
accent: "warning",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
column.text({
|
||||
key: "product",
|
||||
header: t("portal.documents.table.columns.product"),
|
||||
width: "7rem",
|
||||
render: (d) => (
|
||||
<Chip accent={PRODUCT_CHIP_TONE[d.product]} size="sm" showDot={false}>
|
||||
{d.product}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => d.product,
|
||||
}),
|
||||
column.text({
|
||||
key: "action",
|
||||
header: t("portal.documents.table.columns.action"),
|
||||
width: "12rem",
|
||||
render: (d) =>
|
||||
d.product === "Editor" || !d.action ? (
|
||||
<span className="portal-documents__editor-action">
|
||||
{t("portal.documents.table.editorAction")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="portal-documents__action">{d.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) =>
|
||||
d.product === "Editor" || !d.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: d.action,
|
||||
}),
|
||||
column.muted({
|
||||
key: "user",
|
||||
header: t("portal.documents.table.columns.user"),
|
||||
width: "8rem",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.user || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => d.user,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.documents.table.columns.status"),
|
||||
width: "10rem",
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
|
||||
{t(DOCUMENT_STATUS_LABEL[d.status])}
|
||||
{d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => ({
|
||||
tone: DOCUMENT_STATUS_TONE[d.status],
|
||||
label:
|
||||
t(DOCUMENT_STATUS_LABEL[d.status]) +
|
||||
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""),
|
||||
}),
|
||||
}),
|
||||
column.muted({
|
||||
key: "time",
|
||||
header: t("portal.documents.table.columns.time"),
|
||||
width: "7rem",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.time}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
get: (d) => d.time,
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
header: t("portal.documents.table.columns.actions"),
|
||||
headerHidden: true,
|
||||
width: "3rem",
|
||||
render: (d) => (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
leftSection={<KebabIcon />}
|
||||
aria-label={t("portal.documents.table.rowActions")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRowClick(d);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
get: (d) => [
|
||||
{
|
||||
label: t("portal.documents.table.rowActions"),
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
onClick: () => onRowClick(d),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[t, onRowClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<ReviewDocument>
|
||||
className="portal-documents__table"
|
||||
<DataTable<ReviewDocument>
|
||||
columns={columns}
|
||||
rows={documents}
|
||||
rowKey={(d) => d.id}
|
||||
onRowClick={onRowClick}
|
||||
// Every row carries its own actions button, which opens the same document.
|
||||
rowsContainControls
|
||||
empty={t("portal.documents.table.empty")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
type ChipAccent,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
|
||||
import {
|
||||
INSTANCE_STATUS_LABEL,
|
||||
INSTANCE_STATUS_TONE,
|
||||
TARGET_META,
|
||||
type EditorInstance,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
@@ -22,13 +13,6 @@ const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
kubernetes: "K8s",
|
||||
};
|
||||
|
||||
/** Map the target palette tone onto the shared Chip accent set. */
|
||||
const TARGET_CHIP_ACCENT: Record<"neutral" | "blue" | "purple", ChipAccent> = {
|
||||
neutral: "neutral",
|
||||
blue: "default",
|
||||
purple: "premium",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
@@ -37,69 +21,65 @@ interface Props {
|
||||
export function InstanceHealthTable({ instances }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<EditorInstance>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<EditorInstance>[] = [
|
||||
column.entity({
|
||||
key: "host",
|
||||
header: t("portal.editorAdmin.health.columns.host"),
|
||||
render: (i) => (
|
||||
<div className="portal-editor__cell-stack">
|
||||
<span className="portal-editor__cell-strong">{i.host}</span>
|
||||
<span className="portal-editor__cell-muted">
|
||||
<Chip
|
||||
size="sm"
|
||||
accent={TARGET_CHIP_ACCENT[TARGET_META[i.target].tone]}
|
||||
>
|
||||
{TARGET_LABEL[i.target]}
|
||||
</Chip>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (i) => i.host,
|
||||
}),
|
||||
column.text({
|
||||
key: "target",
|
||||
header: t("portal.editorAdmin.health.columns.target", "Target"),
|
||||
sortable: true,
|
||||
get: (i) => TARGET_LABEL[i.target],
|
||||
}),
|
||||
column.mono({
|
||||
key: "version",
|
||||
header: t("portal.editorAdmin.health.columns.version"),
|
||||
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => i.version,
|
||||
}),
|
||||
column.mono({
|
||||
key: "region",
|
||||
header: t("portal.editorAdmin.health.columns.region"),
|
||||
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => i.region,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.editorAdmin.health.columns.status"),
|
||||
render: (i) => (
|
||||
<StatusBadge tone={INSTANCE_STATUS_TONE[i.status]} size="sm">
|
||||
{t(INSTANCE_STATUS_LABEL[i.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => ({
|
||||
tone: INSTANCE_STATUS_TONE[i.status],
|
||||
label: t(INSTANCE_STATUS_LABEL[i.status]),
|
||||
}),
|
||||
}),
|
||||
column.muted({
|
||||
key: "lastSeen",
|
||||
header: t("portal.editorAdmin.health.columns.lastSeen"),
|
||||
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
|
||||
},
|
||||
{
|
||||
get: (i) => i.lastSeen,
|
||||
}),
|
||||
column.number({
|
||||
key: "activeUsers",
|
||||
header: t("portal.editorAdmin.health.columns.activeUsers"),
|
||||
align: "right",
|
||||
render: (i) => (
|
||||
<span className="portal-editor__mono">{i.activeUsers}</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (i) => i.activeUsers,
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<DataTable<EditorInstance>
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => i.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.editorAdmin.health.empty.title")}
|
||||
description={t("portal.editorAdmin.health.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
StatusBadge,
|
||||
Table,
|
||||
Tabs,
|
||||
type TabItem,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -22,10 +21,8 @@ import {
|
||||
} from "@portal/api/infrastructure";
|
||||
import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
|
||||
import {
|
||||
AUDIT_CAT_LABEL,
|
||||
AUDIT_CAT_TONE,
|
||||
AUDIT_STATUS_LABEL,
|
||||
AUDIT_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
@@ -57,55 +54,52 @@ export function AuditTab() {
|
||||
},
|
||||
];
|
||||
|
||||
const cols: TableColumn<AuditEvent>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<AuditEvent>[] = [
|
||||
column.mono({
|
||||
key: "timestamp",
|
||||
header: t("portal.infrastructure.audit.columns.timestamp"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.timestamp,
|
||||
}),
|
||||
column.text({
|
||||
key: "event",
|
||||
header: t("portal.infrastructure.audit.columns.event"),
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{t(AUDIT_CAT_LABEL[e.category])}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// "Category: action" on one line - the category as a bold label, no
|
||||
// status-coloured dot. Colour is reserved for the Status column, where it
|
||||
// actually signals an outcome.
|
||||
label: (e) => t(AUDIT_CAT_LABEL[e.category]),
|
||||
get: (e) => e.action,
|
||||
}),
|
||||
column.mono({
|
||||
key: "actor",
|
||||
header: t("portal.infrastructure.audit.columns.actor"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.actor,
|
||||
}),
|
||||
column.text({
|
||||
key: "target",
|
||||
header: t("portal.infrastructure.audit.columns.target"),
|
||||
render: (e) => e.target,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.target,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.infrastructure.audit.columns.status"),
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{t(AUDIT_STATUS_LABEL[e.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => ({
|
||||
tone: AUDIT_TONE[e.status],
|
||||
label: t(AUDIT_STATUS_LABEL[e.status]),
|
||||
}),
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: t("portal.infrastructure.audit.columns.latency"),
|
||||
align: "right",
|
||||
render: (e) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("portal.infrastructure.audit.latencyValue", {
|
||||
value: e.latencyMs,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (e) => e.latencyMs,
|
||||
format: (n) =>
|
||||
t("portal.infrastructure.audit.latencyValue", { value: n }),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = useAuditLog(tier);
|
||||
@@ -171,31 +165,31 @@ export function AuditTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={6} cols={6} />}
|
||||
{!isLoading && forbidden && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.forbidden.title")}
|
||||
description={t("portal.infrastructure.audit.forbidden.description")}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !forbidden && isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.empty.title")}
|
||||
description={t("portal.infrastructure.audit.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && (
|
||||
<Table
|
||||
columns={cols}
|
||||
rows={rows}
|
||||
rowKey={(e) => e.id}
|
||||
empty={t("portal.infrastructure.audit.noEventsInCategory")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<DataTable
|
||||
columns={cols}
|
||||
rows={rows}
|
||||
rowKey={(e) => e.id}
|
||||
loading={isLoading}
|
||||
empty={
|
||||
forbidden ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.forbidden.title")}
|
||||
description={t(
|
||||
"portal.infrastructure.audit.forbidden.description",
|
||||
)}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.empty.title")}
|
||||
description={t("portal.infrastructure.audit.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
t("portal.infrastructure.audit.noEventsInCategory")
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Card } from "@app/ui";
|
||||
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
|
||||
const meta: Meta<typeof TableSkeleton> = {
|
||||
title: "Portal/Infrastructure/TableSkeleton",
|
||||
component: TableSkeleton,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<Card padding="none" style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</Card>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TableSkeleton>;
|
||||
|
||||
export const Regions: Story = { args: { rows: 3, cols: 9 } };
|
||||
|
||||
export const AuditLog: Story = { args: { rows: 6, cols: 6 } };
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Skeleton } from "@app/ui";
|
||||
|
||||
/** Placeholder grid shown inside a table card while rows are loading. */
|
||||
export function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
|
||||
return (
|
||||
<div className="portal-infra__table-skel" aria-hidden>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="portal-infra__table-skel-row">
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton key={c} height="0.75rem" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,3 @@ export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
|
||||
processing: "portal.infrastructure.auditCatLabel.processing",
|
||||
security: "portal.infrastructure.auditCatLabel.security",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
|
||||
auth: "info",
|
||||
config: "neutral",
|
||||
elevation: "purple",
|
||||
policy: "purple",
|
||||
processing: "success",
|
||||
security: "warning",
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded";
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import type { PipelineStatus, PipelineView } from "@portal/api/pipelines";
|
||||
|
||||
@@ -24,87 +22,56 @@ interface PipelinesTableProps {
|
||||
|
||||
export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<PipelineView>[]>(
|
||||
const columns = useMemo<DataTableColumn<PipelineView>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: t("portal.pipelines.table.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__name-cell">
|
||||
<span className="portal-pipelines__pipe-dot" aria-hidden>
|
||||
<AccountTreeRounded style={{ fontSize: "1.2rem" }} />
|
||||
</span>
|
||||
<div className="portal-pipelines__name-text">
|
||||
<strong>{p.name}</strong>
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t(`portal.pipelines.trigger.${p.trigger}`, {
|
||||
defaultValue: p.trigger,
|
||||
})}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
icon: () => <AccountTreeRounded />,
|
||||
primary: (p) => p.name,
|
||||
}),
|
||||
column.text({
|
||||
key: "trigger",
|
||||
header: t("portal.pipelines.table.trigger", "Trigger"),
|
||||
sortable: true,
|
||||
get: (p) =>
|
||||
t(`portal.pipelines.trigger.${p.trigger}`, {
|
||||
defaultValue: p.trigger,
|
||||
}),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.pipelines.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{t(`portal.pipelines.status.${p.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (p) => ({
|
||||
tone: STATUS_TONE[p.status],
|
||||
label: t(`portal.pipelines.status.${p.status}`),
|
||||
}),
|
||||
}),
|
||||
column.number({
|
||||
key: "steps",
|
||||
header: t("portal.pipelines.table.steps"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.steps.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.steps.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (p) => p.steps.length,
|
||||
}),
|
||||
column.number({
|
||||
key: "sources",
|
||||
header: t("portal.pipelines.table.sources"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.sources.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.sources.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
header: t("portal.pipelines.table.open"),
|
||||
headerHidden: true,
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
render: () => (
|
||||
<span className="portal-pipelines__caret" aria-hidden>
|
||||
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (p) => p.sources.length,
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<PipelineView>
|
||||
className="portal-pipelines__table"
|
||||
<DataTable<PipelineView>
|
||||
columns={columns}
|
||||
rows={pipelines}
|
||||
rowKey={(p) => p.id}
|
||||
onRowClick={onRowClick}
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import "@portal/views/Policies.css";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
|
||||
interface PolicyCatalogueTableProps {
|
||||
entries: CatalogueEntry[];
|
||||
@@ -15,10 +14,9 @@ interface PolicyCatalogueTableProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The policy catalogue as a data table (Policy / Enforces / Applies to / Docs /
|
||||
* Status). The status column resolves per row to a state badge, an info chip
|
||||
* (coming soon / requires AI), or a "Set up" call to action.
|
||||
*/
|
||||
export function PolicyCatalogueTable({
|
||||
entries,
|
||||
@@ -28,109 +26,81 @@ export function PolicyCatalogueTable({
|
||||
}: PolicyCatalogueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo<TableColumn<CatalogueEntry>[]>(
|
||||
const columns = useMemo<DataTableColumn<CatalogueEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
icon: (entry) => policyCategoryIcon(entry.category.id),
|
||||
primary: (entry) => t(entry.category.label),
|
||||
}),
|
||||
column.text({
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
get: (entry) => entry.config.rules.map((r) => t(r)).join(", "),
|
||||
}),
|
||||
column.muted({
|
||||
key: "scope",
|
||||
header: t("portal.policies.table.appliesTo", "Applies to"),
|
||||
render: (entry) => (
|
||||
<span className="portal-policies__muted">
|
||||
{t(entry.config.scopeLabel)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (entry) => t(entry.config.scopeLabel),
|
||||
}),
|
||||
column.number({
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (entry) => (entry.policy ? entry.policy.stats.enforced : null),
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.policies.table.status", "Status"),
|
||||
align: "right",
|
||||
width: "8.5rem",
|
||||
render: (entry) => {
|
||||
sortable: true,
|
||||
// Every state reads as one badge; the "set up" affordance is the row
|
||||
// itself (click + chevron), so there's no bespoke button in the cell.
|
||||
get: (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>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: t("portal.policies.card.comingSoon"),
|
||||
};
|
||||
}
|
||||
if (isLocked?.(entry)) {
|
||||
return (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
|
||||
</Chip>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: lockedLabel ?? t("portal.policies.card.requiresAiEngine"),
|
||||
};
|
||||
}
|
||||
if (entry.policy) {
|
||||
const paused = entry.policy.state.status === "paused";
|
||||
return (
|
||||
<StatusBadge tone={paused ? "warning" : "success"} size="sm">
|
||||
{paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
</StatusBadge>
|
||||
);
|
||||
return {
|
||||
tone: paused ? "warning" : "success",
|
||||
label: paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active"),
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={() => onOpen(entry)}>
|
||||
{t("portal.policySummary.action.setUp")}
|
||||
</Button>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: t("portal.policySummary.action.setUp"),
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t, onOpen, isLocked, lockedLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<CatalogueEntry>
|
||||
className="portal-policies__table"
|
||||
<DataTable<CatalogueEntry>
|
||||
columns={columns}
|
||||
rows={entries}
|
||||
rowKey={(e) => e.category.id}
|
||||
onRowClick={(entry) =>
|
||||
entry.category.comingSoon || isLocked?.(entry)
|
||||
? undefined
|
||||
: onOpen(entry)
|
||||
onRowClick={onOpen}
|
||||
isRowInteractive={(e) =>
|
||||
!(e.category.comingSoon || (isLocked?.(e) ?? false))
|
||||
}
|
||||
// A category with no policy yet renders a "set up" button, which opens the same thing.
|
||||
rowsContainControls
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import type { SourceStatus, SourceView } from "@portal/api/sources";
|
||||
import {
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
sourceTypeMeta,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const STATUS_TONE: Record<SourceStatus, StatusTone> = {
|
||||
active: "success",
|
||||
@@ -30,95 +27,65 @@ interface SourcesTableProps {
|
||||
|
||||
export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<SourceView>[]>(
|
||||
const columns = useMemo<DataTableColumn<SourceView>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: t("portal.sources.table.source"),
|
||||
render: (s) => {
|
||||
const meta = sourceTypeMeta(s.type);
|
||||
// The editor is a system source with no instance name: label it from its type and drop
|
||||
// the chip, which would just repeat the name.
|
||||
const isEditor = s.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<div className="portal-sources__name-cell">
|
||||
<span className="portal-sources__type-dot" aria-hidden>
|
||||
<SourceTypeIcon type={s.type} />
|
||||
</span>
|
||||
<div className="portal-sources__name-text">
|
||||
<strong>{isEditor ? t(meta.labelKey) : s.name}</strong>
|
||||
{!isEditor && (
|
||||
<Chip accent={meta.accent} size="sm">
|
||||
{t(meta.labelKey)}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
icon: (s) => <SourceTypeIcon type={s.type} />,
|
||||
// The editor is a system source with no instance name: label it from its
|
||||
// type (and leave its Type cell blank, so it isn't repeated).
|
||||
primary: (s) =>
|
||||
s.type === EDITOR_SOURCE_TYPE
|
||||
? t(sourceTypeMeta(s.type).labelKey)
|
||||
: s.name,
|
||||
}),
|
||||
column.text({
|
||||
key: "type",
|
||||
header: t("portal.sources.table.type", "Type"),
|
||||
sortable: true,
|
||||
get: (s) =>
|
||||
s.type === EDITOR_SOURCE_TYPE
|
||||
? ""
|
||||
: t(sourceTypeMeta(s.type).labelKey),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.sources.table.status"),
|
||||
render: (s) => (
|
||||
<StatusBadge tone={STATUS_TONE[s.status]} size="sm">
|
||||
{t(`portal.sources.status.${s.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (s) => ({
|
||||
tone: STATUS_TONE[s.status],
|
||||
label: t(`portal.sources.status.${s.status}`),
|
||||
}),
|
||||
}),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: t("portal.sources.table.documents"),
|
||||
align: "right",
|
||||
render: (s) => (
|
||||
<span
|
||||
className={s.docsTotal === 0 ? "portal-sources__muted" : undefined}
|
||||
>
|
||||
{s.docsTotal.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (s) => s.docsTotal,
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.number({
|
||||
key: "referenceCount",
|
||||
header: t("portal.sources.table.usedBy"),
|
||||
align: "right",
|
||||
render: (s) => (
|
||||
<span
|
||||
className={
|
||||
s.referenceCount === 0 ? "portal-sources__muted" : undefined
|
||||
}
|
||||
>
|
||||
{s.referenceCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
header: t("portal.sources.table.open"),
|
||||
headerHidden: true,
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
// The editor source has no page to open, so it shows no chevron.
|
||||
render: (s) =>
|
||||
s.type === EDITOR_SOURCE_TYPE ? null : (
|
||||
<span className="portal-sources__caret" aria-hidden>
|
||||
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (s) => s.referenceCount,
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<SourceView>
|
||||
className="portal-sources__table"
|
||||
<DataTable<SourceView>
|
||||
columns={columns}
|
||||
rows={sources}
|
||||
rowKey={(s) => s.id}
|
||||
onRowClick={onRowClick}
|
||||
// The virtual editor row has no page to open, so it's inert - not a fake button.
|
||||
// The virtual editor row has no page to open, so it's inert (and gets no
|
||||
// chevron) - not a fake button.
|
||||
isRowInteractive={(s) => s.type !== EDITOR_SOURCE_TYPE}
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("PendingInvitations", () => {
|
||||
expect(screen.getByText("sam@acme.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("dana@acme.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Invited by leader@acme.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 pending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pending invitations")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancelling an invite calls back with that invitation", () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Avatar, Button } from "@app/ui";
|
||||
import { Avatar, column, DataTable, type DataTableColumn } from "@app/ui";
|
||||
import type { PendingInvitation } from "@portal/api/users";
|
||||
import "@portal/theme/surface.css";
|
||||
import "@portal/views/Users.css";
|
||||
|
||||
interface PendingInvitationsProps {
|
||||
invitations: PendingInvitation[];
|
||||
@@ -25,60 +24,54 @@ function expiryLabel(iso: string | undefined, t: TFunction): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending team invitations (SaaS): the parity gap vs the editor. Each row shows
|
||||
* the invitee and lets a team leader cancel the invite. Rendered only when the
|
||||
* flavor supports it (manageInvitations) and there are pending invites.
|
||||
* Pending team invitations (SaaS): the parity gap vs the editor. A single flat
|
||||
* list (not a section - there's only ever one) captioned with its title; each
|
||||
* row shows the invitee and lets a team leader cancel the invite.
|
||||
*/
|
||||
export function PendingInvitations({
|
||||
invitations,
|
||||
onCancel,
|
||||
}: PendingInvitationsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo<DataTableColumn<PendingInvitation>[]>(
|
||||
() => [
|
||||
column.entity({
|
||||
key: "invitee",
|
||||
header: t("users.invites.columns.invitee", "Invitee"),
|
||||
icon: (inv) => <Avatar name={inv.email} size="sm" tone="neutral" />,
|
||||
primary: (inv) => inv.email,
|
||||
note: (inv) =>
|
||||
inv.invitedBy
|
||||
? t("users.invites.by", "Invited by {{who}}", {
|
||||
who: inv.invitedBy,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
column.muted({
|
||||
key: "expires",
|
||||
header: t("users.invites.columns.expires", "Expires"),
|
||||
get: (inv) => expiryLabel(inv.expiresAt, t),
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
get: (inv) => [
|
||||
{
|
||||
label: t("users.invites.cancel", "Cancel"),
|
||||
onClick: () => onCancel(inv),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[t, onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="portal-surface portal-users__group">
|
||||
<header className="portal-users__group-head">
|
||||
<div className="portal-users__group-title">
|
||||
<strong>{t("users.invites.title", "Pending invitations")}</strong>
|
||||
<span className="portal-users__group-desc">
|
||||
{t(
|
||||
"users.invites.desc",
|
||||
"Invited people who haven't joined yet. They hold a seat until they accept.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-users__group-count">
|
||||
{t("users.invites.count", "{{count}} pending", {
|
||||
count: invitations.length,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
{invitations.map((inv) => {
|
||||
const expires = expiryLabel(inv.expiresAt, t);
|
||||
return (
|
||||
<div className="portal-users__row" key={inv.id}>
|
||||
<div className="portal-users__row-main">
|
||||
<Avatar name={inv.email} size="sm" tone="neutral" />
|
||||
<div className="portal-users__row-id">
|
||||
<span className="portal-users__row-name">{inv.email}</span>
|
||||
{inv.invitedBy && (
|
||||
<span className="portal-users__row-email">
|
||||
{t("users.invites.by", "Invited by {{who}}", {
|
||||
who: inv.invitedBy,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="portal-users__inv-spacer" />
|
||||
{expires && (
|
||||
<span className="portal-users__row-active">{expires}</span>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => onCancel(inv)}>
|
||||
{t("users.invites.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<DataTable<PendingInvitation>
|
||||
columns={columns}
|
||||
rows={invitations}
|
||||
rowKey={(inv) => String(inv.id)}
|
||||
caption={t("users.invites.title", "Pending invitations")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ const MEMBERS: Member[] = [
|
||||
name: "Priya Patel",
|
||||
email: "priya@stirlingpdf.com",
|
||||
role: "member",
|
||||
status: "active",
|
||||
status: "suspended",
|
||||
lastActive: "Never",
|
||||
username: "priya",
|
||||
teamId: 2,
|
||||
@@ -126,6 +126,7 @@ const MEMBERS: Member[] = [
|
||||
teamName: "Compliance",
|
||||
portalAccess: "granted",
|
||||
portalGrantId: 11,
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@mantine/core";
|
||||
import PersonAddAltRounded from "@mui/icons-material/PersonAddAltRounded";
|
||||
import { Avatar, Chip, Select } from "@app/ui";
|
||||
import {
|
||||
Avatar,
|
||||
type CellAction,
|
||||
type CellCap,
|
||||
type CellLabel,
|
||||
type CellMenuItem,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
type DataTableGroup,
|
||||
} from "@app/ui";
|
||||
import { type Member, type RoleId } from "@portal/api/users";
|
||||
import type { Team } from "@portal/api/teams";
|
||||
import type { UsersCapabilities } from "@portal/api/usersCapabilities";
|
||||
@@ -11,8 +19,6 @@ import {
|
||||
buildDirectory,
|
||||
type TeamGroup,
|
||||
} from "@portal/components/users/directory";
|
||||
import "@portal/theme/surface.css";
|
||||
import "@portal/views/Users.css";
|
||||
|
||||
/** Collapse a group's rows past this many, behind a "Show all" expander. */
|
||||
const COLLAPSED_LIMIT = 8;
|
||||
@@ -43,23 +49,17 @@ interface UsersDirectoryProps {
|
||||
// Team actions (the team-header kebab).
|
||||
onRenameTeam: (team: TeamGroup) => void;
|
||||
onDeleteTeam: (team: TeamGroup) => void;
|
||||
/**
|
||||
* Show the "Approves policy" capability chip on org owners. Off in the live
|
||||
* app (no backend for it yet); on in Storybook to document the intended design.
|
||||
*/
|
||||
/** Show the "Approves policy" capability on org owners (Storybook design doc). */
|
||||
showApprover?: boolean;
|
||||
/**
|
||||
* Show the Guests group + the "Guest" role option. Off in the live app (the
|
||||
* Guest concept is parked); on in Storybook to document the intended design.
|
||||
*/
|
||||
/** Show the Guests group + the "Guest" role option (Storybook design doc). */
|
||||
showGuests?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The people roster, grouped like the org chart: Organization owners, then each
|
||||
* team (with its leader), each row carrying capability chips, a role selector,
|
||||
* and a kebab of admin actions. Long groups collapse behind a "Show all" so big
|
||||
* orgs stay scannable.
|
||||
* The people roster on the shared DataTable: grouped like the org chart
|
||||
* (Organization owners, then each team, then guests), each row carrying its
|
||||
* capability chips, a role selector, and a kebab of admin actions. Long groups
|
||||
* collapse behind a "Show all".
|
||||
*/
|
||||
export function UsersDirectory({
|
||||
members,
|
||||
@@ -85,7 +85,6 @@ export function UsersDirectory({
|
||||
}: UsersDirectoryProps) {
|
||||
const { t } = useTranslation();
|
||||
const dir = useMemo(() => buildDirectory(members, teams), [members, teams]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const nameByUsername = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
@@ -95,393 +94,338 @@ export function UsersDirectory({
|
||||
return m;
|
||||
}, [members]);
|
||||
|
||||
const roleOptions: { value: RoleId; label: string }[] = [
|
||||
// No SaaS user is ever ROLE_ADMIN, so the Org Owner option is dropped there.
|
||||
...(capabilities.adminRole
|
||||
? [
|
||||
{
|
||||
value: "admin" as RoleId,
|
||||
label: t("users.role.orgOwner", "Org Owner"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ value: "team_owner", label: t("users.role.teamOwner", "Team Owner") },
|
||||
{ value: "member", label: t("users.role.member", "Member") },
|
||||
...(showGuests
|
||||
? [{ value: "guest" as RoleId, label: t("users.role.guest", "Guest") }]
|
||||
: []),
|
||||
];
|
||||
const roleOptions = useMemo(
|
||||
() => [
|
||||
// No SaaS user is ever ROLE_ADMIN, so the Org Owner option is dropped there.
|
||||
...(capabilities.adminRole
|
||||
? [
|
||||
{
|
||||
value: "admin" as RoleId,
|
||||
label: t("users.role.orgOwner", "Org Owner"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: "team_owner" as RoleId,
|
||||
label: t("users.role.teamOwner", "Team Owner"),
|
||||
},
|
||||
{ value: "member" as RoleId, label: t("users.role.member", "Member") },
|
||||
...(showGuests
|
||||
? [{ value: "guest" as RoleId, label: t("users.role.guest", "Guest") }]
|
||||
: []),
|
||||
],
|
||||
[capabilities.adminRole, showGuests, t],
|
||||
);
|
||||
|
||||
function toggleExpand(key: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const columns = useMemo<DataTableColumn<Member>[]>(() => {
|
||||
function rowKebab(m: Member): CellAction {
|
||||
const removeLabel =
|
||||
capabilities.removeScope === "team"
|
||||
? t("users.action.removeTeam", "Remove from team")
|
||||
: t("users.action.remove", "Remove from org");
|
||||
const items: CellMenuItem[] = [];
|
||||
if (capabilities.resetPassword) {
|
||||
items.push({
|
||||
label: t("users.action.resetPw", "Reset password"),
|
||||
disabled: m.isSelf,
|
||||
onClick: () => onResetPassword(m),
|
||||
});
|
||||
}
|
||||
if (capabilities.moveTeam) {
|
||||
items.push({
|
||||
label: t("users.action.move", "Move to team"),
|
||||
onClick: () => onMoveToTeam(m),
|
||||
});
|
||||
}
|
||||
if (capabilities.suspend) {
|
||||
items.push({
|
||||
label:
|
||||
m.status === "suspended"
|
||||
? t("users.action.reinstate", "Reinstate")
|
||||
: t("users.action.suspend", "Suspend"),
|
||||
disabled: m.isSelf,
|
||||
onClick: () => onToggleEnabled(m),
|
||||
});
|
||||
}
|
||||
if (capabilities.unlock && m.locked) {
|
||||
items.push({
|
||||
label: t("users.action.unlock", "Unlock account"),
|
||||
onClick: () => onUnlock(m),
|
||||
});
|
||||
}
|
||||
if (capabilities.resetMfa && m.mfaEnabled) {
|
||||
items.push({
|
||||
label: t("users.action.disableMfa", "Reset MFA"),
|
||||
disabled: m.isSelf,
|
||||
onClick: () => onDisableMfa(m),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
label: removeLabel,
|
||||
tone: "danger",
|
||||
disabled: m.isSelf,
|
||||
onClick: () => onRemove(m),
|
||||
dividerBefore: items.length > 0,
|
||||
});
|
||||
return {
|
||||
label: t("users.rowActions", "Actions for {{name}}", { name: m.name }),
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
menu: items,
|
||||
};
|
||||
}
|
||||
|
||||
function ownerNames(owners: string[]): string {
|
||||
return owners.map((u) => nameByUsername.get(u) ?? u).join(", ");
|
||||
}
|
||||
|
||||
// A team whose name/membership is managed by the system - no rename/delete.
|
||||
// SaaS personal teams (isPersonal) reject rename/delete at the backend too.
|
||||
function isManagedTeam(team: TeamGroup): boolean {
|
||||
return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true;
|
||||
}
|
||||
|
||||
// Whether the team-header kebab has any actions (else it isn't rendered).
|
||||
function teamKebabHasItems(team: TeamGroup): boolean {
|
||||
return (
|
||||
capabilities.manageGrants ||
|
||||
(!isManagedTeam(team) &&
|
||||
(capabilities.renameTeam || capabilities.deleteTeam))
|
||||
);
|
||||
}
|
||||
|
||||
// Whether any action sits above the "Remove" item (so we render a divider).
|
||||
function rowKebabHasUpperActions(m: Member): boolean {
|
||||
return (
|
||||
capabilities.resetPassword ||
|
||||
capabilities.moveTeam ||
|
||||
capabilities.suspend ||
|
||||
(capabilities.unlock && !!m.locked) ||
|
||||
(capabilities.resetMfa && !!m.mfaEnabled)
|
||||
);
|
||||
}
|
||||
|
||||
function rowKebab(m: Member) {
|
||||
// Both flavors have a backed remove now: org-delete (self-hosted) or
|
||||
// team-remove (SaaS, via SaasTeamController's remove-member endpoint), so the
|
||||
// kebab always carries at least the remove action.
|
||||
const removeLabel =
|
||||
capabilities.removeScope === "team"
|
||||
? t("users.action.removeTeam", "Remove from team")
|
||||
: t("users.action.remove", "Remove from org");
|
||||
return (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={210}>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-users__row-kebab"
|
||||
aria-label={t("users.rowActions", "Actions for {{name}}", {
|
||||
name: m.name,
|
||||
})}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{capabilities.resetPassword && (
|
||||
<Menu.Item disabled={m.isSelf} onClick={() => onResetPassword(m)}>
|
||||
{t("users.action.resetPw", "Reset password")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{capabilities.moveTeam && (
|
||||
<Menu.Item onClick={() => onMoveToTeam(m)}>
|
||||
{t("users.action.move", "Move to team")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{capabilities.suspend && (
|
||||
<Menu.Item disabled={m.isSelf} onClick={() => onToggleEnabled(m)}>
|
||||
{m.status === "suspended"
|
||||
? t("users.action.reinstate", "Reinstate")
|
||||
: t("users.action.suspend", "Suspend")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{capabilities.unlock && m.locked && (
|
||||
<Menu.Item onClick={() => onUnlock(m)}>
|
||||
{t("users.action.unlock", "Unlock account")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{capabilities.resetMfa && m.mfaEnabled && (
|
||||
<Menu.Item disabled={m.isSelf} onClick={() => onDisableMfa(m)}>
|
||||
{t("users.action.disableMfa", "Reset MFA")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{rowKebabHasUpperActions(m) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
disabled={m.isSelf}
|
||||
onClick={() => onRemove(m)}
|
||||
>
|
||||
{removeLabel}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRow(m: Member) {
|
||||
const access = m.portalAccess ?? "none";
|
||||
return (
|
||||
// data-member-id lets deep links (?member=<id>) scroll to and flash a row.
|
||||
<div className="portal-users__row" key={m.id} data-member-id={m.id}>
|
||||
<div className="portal-users__row-main">
|
||||
const cols: DataTableColumn<Member>[] = [
|
||||
column.entity({
|
||||
key: "person",
|
||||
header: t("users.columns.person", "Person"),
|
||||
icon: (m) => (
|
||||
<Avatar name={m.name} size="sm" tone={avatarToneForMember(m)} />
|
||||
<div className="portal-users__row-id">
|
||||
<span className="portal-users__row-name">
|
||||
{m.name}
|
||||
{m.isSelf && (
|
||||
<span className="portal-users__row-you">
|
||||
{" "}
|
||||
{t("users.you", "(you)")}
|
||||
</span>
|
||||
)}
|
||||
{m.status === "suspended" && (
|
||||
<Chip
|
||||
accent="danger"
|
||||
size="sm"
|
||||
className="portal-users__row-tag"
|
||||
>
|
||||
{t("users.suspended", "Suspended")}
|
||||
</Chip>
|
||||
)}
|
||||
{m.locked && (
|
||||
<Chip
|
||||
accent="warning"
|
||||
size="sm"
|
||||
className="portal-users__row-tag"
|
||||
>
|
||||
{t("users.locked", "Locked")}
|
||||
</Chip>
|
||||
)}
|
||||
</span>
|
||||
{m.email !== m.name && (
|
||||
<span className="portal-users__row-email">{m.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
primary: (m) => m.name,
|
||||
suffix: (m) => (m.isSelf ? t("users.you", "(you)") : undefined),
|
||||
}),
|
||||
column.muted({
|
||||
key: "email",
|
||||
header: t("users.columns.email", "Email"),
|
||||
// Blank when the name already is the email (no separate display name).
|
||||
get: (m) => (m.email !== m.name ? m.email : undefined),
|
||||
}),
|
||||
column.labels({
|
||||
key: "status",
|
||||
header: t("users.columns.status", "Status"),
|
||||
get: (m) => {
|
||||
const out: CellLabel[] = [];
|
||||
if (m.status === "suspended") {
|
||||
out.push({
|
||||
label: t("users.suspended", "Suspended"),
|
||||
accent: "danger",
|
||||
});
|
||||
}
|
||||
if (m.locked) {
|
||||
out.push({ label: t("users.locked", "Locked"), accent: "warning" });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
column.caps({
|
||||
key: "capabilities",
|
||||
header: t("users.columns.capabilities", "Capabilities"),
|
||||
get: (m) => {
|
||||
const access = m.portalAccess ?? "none";
|
||||
const out: CellCap[] = [
|
||||
{ label: t("users.cap.editor", "Editor"), accent: "neutral" },
|
||||
];
|
||||
if (access === "granted") {
|
||||
out.push({
|
||||
label: t("users.cap.processor", "Processor"),
|
||||
accent: "default",
|
||||
onRemove: capabilities.manageGrants
|
||||
? () => onRevokeProcessor(m)
|
||||
: undefined,
|
||||
});
|
||||
} else if (access !== "none") {
|
||||
out.push({
|
||||
label: t("users.cap.processor", "Processor"),
|
||||
accent: "default",
|
||||
});
|
||||
} else if (capabilities.manageGrants) {
|
||||
out.push({
|
||||
label: t("users.cap.addProcessor", "+ Processor"),
|
||||
accent: "neutral",
|
||||
dashed: true,
|
||||
onClick: () => onGrantProcessor(m),
|
||||
});
|
||||
}
|
||||
if (showApprover && m.role === "admin") {
|
||||
out.push({
|
||||
label: t("users.cap.approver", "Approves policy"),
|
||||
accent: "success",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
column.muted({
|
||||
key: "lastActive",
|
||||
header: t("users.lastActive", "Last active"),
|
||||
get: (m) => m.lastActive,
|
||||
}),
|
||||
];
|
||||
|
||||
<div className="portal-users__caps">
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("users.cap.editor", "Editor")}
|
||||
</Chip>
|
||||
{access === "granted" ? (
|
||||
<Chip
|
||||
accent="default"
|
||||
size="sm"
|
||||
onRemove={
|
||||
capabilities.manageGrants
|
||||
? () => onRevokeProcessor(m)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("users.cap.processor", "Processor")}
|
||||
</Chip>
|
||||
) : access !== "none" ? (
|
||||
// admin / team-owner role / inherited from a team-wide grant
|
||||
<Chip accent="default" size="sm">
|
||||
{t("users.cap.processor", "Processor")}
|
||||
</Chip>
|
||||
) : capabilities.manageGrants ? (
|
||||
<Chip
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
dashed
|
||||
onClick={() => onGrantProcessor(m)}
|
||||
>
|
||||
{t("users.cap.addProcessor", "+ Processor")}
|
||||
</Chip>
|
||||
) : null}
|
||||
{showApprover && m.role === "admin" && (
|
||||
<Chip
|
||||
accent="success"
|
||||
size="sm"
|
||||
leadingIcon={<span aria-hidden>✓</span>}
|
||||
>
|
||||
{t("users.cap.approver", "Approves policy")}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
if (capabilities.changeRole) {
|
||||
cols.push(
|
||||
column.select({
|
||||
key: "role",
|
||||
header: t("users.columns.role", "Role"),
|
||||
get: (m) => ({
|
||||
value: m.role,
|
||||
options: roleOptions,
|
||||
ariaLabel: t("users.roleFor", "Role for {{name}}", {
|
||||
name: m.name,
|
||||
}),
|
||||
disabled: m.isSelf,
|
||||
}),
|
||||
onChange: (m, value) => onChangeRole(m, (value ?? m.role) as RoleId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
<span
|
||||
className="portal-users__row-active"
|
||||
title={t("users.lastActive", "Last active")}
|
||||
>
|
||||
{m.lastActive}
|
||||
</span>
|
||||
cols.push(column.actions({ key: "actions", get: (m) => [rowKebab(m)] }));
|
||||
return cols;
|
||||
}, [
|
||||
t,
|
||||
capabilities,
|
||||
roleOptions,
|
||||
showApprover,
|
||||
onChangeRole,
|
||||
onGrantProcessor,
|
||||
onRevokeProcessor,
|
||||
onResetPassword,
|
||||
onMoveToTeam,
|
||||
onToggleEnabled,
|
||||
onUnlock,
|
||||
onDisableMfa,
|
||||
onRemove,
|
||||
]);
|
||||
|
||||
{capabilities.changeRole && (
|
||||
<div className="portal-users__row-role">
|
||||
<Select
|
||||
aria-label={t("users.roleFor", "Role for {{name}}", {
|
||||
name: m.name,
|
||||
})}
|
||||
options={roleOptions}
|
||||
value={m.role}
|
||||
disabled={m.isSelf}
|
||||
onChange={(value) => onChangeRole(m, (value ?? m.role) as RoleId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
const groups = useMemo<DataTableGroup<Member>[]>(() => {
|
||||
function ownerNames(owners: string[]): string {
|
||||
return owners.map((u) => nameByUsername.get(u) ?? u).join(", ");
|
||||
}
|
||||
// A team whose name/membership is system-managed - no rename/delete.
|
||||
function isManagedTeam(team: TeamGroup): boolean {
|
||||
return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true;
|
||||
}
|
||||
function teamKebabHasItems(team: TeamGroup): boolean {
|
||||
return (
|
||||
capabilities.manageGrants ||
|
||||
(!isManagedTeam(team) &&
|
||||
(capabilities.renameTeam || capabilities.deleteTeam))
|
||||
);
|
||||
}
|
||||
function teamActions(team: TeamGroup): CellAction[] {
|
||||
const acts: CellAction[] = [
|
||||
{
|
||||
label: t("users.group.addToTeam", "Add to team"),
|
||||
onClick: () => onAddToTeam(team),
|
||||
},
|
||||
];
|
||||
if (teamKebabHasItems(team)) {
|
||||
const items: CellMenuItem[] = [];
|
||||
if (capabilities.manageGrants) {
|
||||
items.push(
|
||||
processorTeamIds.has(team.id)
|
||||
? {
|
||||
label: t(
|
||||
"users.team.revokeProcessor",
|
||||
"Revoke Processor from team",
|
||||
),
|
||||
onClick: () => onRevokeTeamProcessor(team),
|
||||
}
|
||||
: {
|
||||
label: t(
|
||||
"users.team.grantProcessor",
|
||||
"Grant Processor to team",
|
||||
),
|
||||
onClick: () => onGrantTeamProcessor(team),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isManagedTeam(team)) {
|
||||
const divider = capabilities.manageGrants;
|
||||
if (capabilities.renameTeam) {
|
||||
items.push({
|
||||
label: t("users.action.rename", "Rename team"),
|
||||
onClick: () => onRenameTeam(team),
|
||||
dividerBefore: divider,
|
||||
});
|
||||
}
|
||||
if (capabilities.deleteTeam) {
|
||||
items.push({
|
||||
label: t("users.action.deleteTeam", "Delete team"),
|
||||
tone: "danger",
|
||||
onClick: () => onDeleteTeam(team),
|
||||
dividerBefore: divider && !capabilities.renameTeam,
|
||||
});
|
||||
}
|
||||
}
|
||||
acts.push({
|
||||
label: t("users.teamActions", "Team actions"),
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
menu: items,
|
||||
});
|
||||
}
|
||||
return acts;
|
||||
}
|
||||
|
||||
{rowKebab(m)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows for a group, collapsing past COLLAPSED_LIMIT behind a toggle. */
|
||||
function renderMembers(list: Member[], key: string) {
|
||||
const isOpen = expanded.has(key);
|
||||
const overflow = list.length > COLLAPSED_LIMIT;
|
||||
const shown = overflow && !isOpen ? list.slice(0, COLLAPSED_LIMIT) : list;
|
||||
return (
|
||||
<>
|
||||
{shown.map(renderRow)}
|
||||
{overflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-users__show-more"
|
||||
onClick={() => toggleExpand(key)}
|
||||
>
|
||||
{isOpen
|
||||
? t("users.showLess", "Show less")
|
||||
: t("users.showAll", "Show all {{count}}", {
|
||||
count: list.length,
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const gs: DataTableGroup<Member>[] = [];
|
||||
if (capabilities.orgGroup && dir.organization.length > 0) {
|
||||
gs.push({
|
||||
key: "org",
|
||||
title: t("users.group.org", "Organization"),
|
||||
meta: t("users.group.owners", "{{count}} owner", {
|
||||
count: dir.organization.length,
|
||||
}),
|
||||
rows: dir.organization,
|
||||
collapseAfter: COLLAPSED_LIMIT,
|
||||
});
|
||||
}
|
||||
for (const team of dir.teams) {
|
||||
const led =
|
||||
team.owners.length > 0
|
||||
? ` · ${t("users.group.ledBy", "led by {{owner}}", {
|
||||
owner: ownerNames(team.owners),
|
||||
})}`
|
||||
: "";
|
||||
gs.push({
|
||||
key: `team-${team.id}`,
|
||||
title: t("users.group.team", "{{name}} team", { name: team.name }),
|
||||
meta:
|
||||
t("users.group.teamMeta", "{{count}} people", {
|
||||
count: team.members.length,
|
||||
}) + led,
|
||||
actions: teamActions(team),
|
||||
rows: team.members,
|
||||
collapseAfter: COLLAPSED_LIMIT,
|
||||
});
|
||||
}
|
||||
if (showGuests && dir.guests.length > 0) {
|
||||
gs.push({
|
||||
key: "guests",
|
||||
title: t("users.group.guests", "Guests"),
|
||||
meta: t("users.group.guestCount", "{{count}} guest", {
|
||||
count: dir.guests.length,
|
||||
}),
|
||||
rows: dir.guests,
|
||||
collapseAfter: COLLAPSED_LIMIT,
|
||||
});
|
||||
}
|
||||
return gs;
|
||||
}, [
|
||||
t,
|
||||
dir,
|
||||
nameByUsername,
|
||||
capabilities,
|
||||
showGuests,
|
||||
processorTeamIds,
|
||||
onAddToTeam,
|
||||
onGrantTeamProcessor,
|
||||
onRevokeTeamProcessor,
|
||||
onRenameTeam,
|
||||
onDeleteTeam,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="portal-users__directory">
|
||||
{/* Organization (a single-org deployment only; SaaS has no org). */}
|
||||
{capabilities.orgGroup && dir.organization.length > 0 && (
|
||||
<section className="portal-surface portal-users__group">
|
||||
<header className="portal-users__group-head">
|
||||
<div className="portal-users__group-title">
|
||||
<strong>{t("users.group.org", "Organization")}</strong>
|
||||
<span className="portal-users__group-desc">
|
||||
{t(
|
||||
"users.group.orgDesc",
|
||||
"Owners with org-wide authority and policy approval",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-users__group-count">
|
||||
{t("users.group.owners", "{{count}} owner", {
|
||||
count: dir.organization.length,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
{renderMembers(dir.organization, "org")}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Teams */}
|
||||
{dir.teams.map((team) => (
|
||||
<section className="portal-surface portal-users__group" key={team.id}>
|
||||
<header className="portal-users__group-head">
|
||||
<div className="portal-users__group-title">
|
||||
<strong>
|
||||
{t("users.group.team", "{{name}} team", { name: team.name })}
|
||||
</strong>
|
||||
<span className="portal-users__group-desc">
|
||||
{t("users.group.teamMeta", "{{count}} people", {
|
||||
count: team.members.length,
|
||||
})}
|
||||
{team.owners.length > 0 &&
|
||||
` · ${t("users.group.ledBy", "led by {{owner}}", {
|
||||
owner: ownerNames(team.owners),
|
||||
})}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-users__group-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-users__group-action"
|
||||
onClick={() => onAddToTeam(team)}
|
||||
>
|
||||
<PersonAddAltRounded sx={{ fontSize: 15 }} />
|
||||
{t("users.group.addToTeam", "Add to team")}
|
||||
</button>
|
||||
{teamKebabHasItems(team) && (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
width={210}
|
||||
>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-users__row-kebab"
|
||||
aria-label={t("users.teamActions", "Team actions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{capabilities.manageGrants &&
|
||||
(processorTeamIds.has(team.id) ? (
|
||||
<Menu.Item onClick={() => onRevokeTeamProcessor(team)}>
|
||||
{t(
|
||||
"users.team.revokeProcessor",
|
||||
"Revoke Processor from team",
|
||||
)}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item onClick={() => onGrantTeamProcessor(team)}>
|
||||
{t(
|
||||
"users.team.grantProcessor",
|
||||
"Grant Processor to team",
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{!isManagedTeam(team) &&
|
||||
(capabilities.renameTeam || capabilities.deleteTeam) && (
|
||||
<>
|
||||
{capabilities.manageGrants && <Menu.Divider />}
|
||||
{capabilities.renameTeam && (
|
||||
<Menu.Item onClick={() => onRenameTeam(team)}>
|
||||
{t("users.action.rename", "Rename team")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{capabilities.deleteTeam && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={() => onDeleteTeam(team)}
|
||||
>
|
||||
{t("users.action.deleteTeam", "Delete team")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{renderMembers(team.members, `team-${team.id}`)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Guests (parked in the live app; shown when showGuests is set). */}
|
||||
{showGuests && dir.guests.length > 0 && (
|
||||
<section className="portal-surface portal-users__group">
|
||||
<header className="portal-users__group-head">
|
||||
<div className="portal-users__group-title">
|
||||
<strong>{t("users.group.guests", "Guests")}</strong>
|
||||
<span className="portal-users__group-desc">
|
||||
{t(
|
||||
"users.group.guestsDesc",
|
||||
"External collaborators, scoped to what you shared. Editor only.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-users__group-count">
|
||||
{t("users.group.guestCount", "{{count}} guest", {
|
||||
count: dir.guests.length,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
{renderMembers(dir.guests, "guests")}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<DataTable<Member>
|
||||
columns={columns}
|
||||
groups={groups}
|
||||
rowKey={(m) => String(m.id)}
|
||||
collapseLabels={{
|
||||
showAll: (count) => t("users.showAll", "Show all {{count}}", { count }),
|
||||
showLess: t("users.showLess", "Show less"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,39 +68,35 @@ describe("Integrations view", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups connections of the same type and expands to the instances", async () => {
|
||||
it("groups connections of the same type, instances shown as rows (no expand)", async () => {
|
||||
fetchIntegrations.mockResolvedValue([
|
||||
bucket(1, "Claims"),
|
||||
bucket(2, "Archive"),
|
||||
]);
|
||||
render(<Integrations />);
|
||||
|
||||
// One connected group row for S3 with the instance count, not two rows.
|
||||
const group = await screen.findByText(
|
||||
"portal.integrations.connectionCount",
|
||||
);
|
||||
expect(group).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("portal.connections.types.s3.label"));
|
||||
// Instances are rows directly under the S3 vendor group - no expand click.
|
||||
expect(await screen.findByText("Claims")).toBeInTheDocument();
|
||||
expect(screen.getByText("Archive")).toBeInTheDocument();
|
||||
// Vendor group header shows the instance count and the "add another" action.
|
||||
expect(
|
||||
screen.getByText("portal.integrations.addAnother"),
|
||||
screen.getByText("portal.integrations.connectionCount"),
|
||||
).toBeInTheDocument();
|
||||
// Each connected vendor group offers a Connect action (to add another).
|
||||
expect(
|
||||
screen.getAllByText("portal.integrations.connect").length,
|
||||
).toBeGreaterThan(0);
|
||||
// The available band remains for the other, unconnected vendors.
|
||||
expect(
|
||||
screen.getByText(/portal\.integrations\.availableHeading/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes an instance from the expanded group", async () => {
|
||||
it("deletes an instance directly from its row", async () => {
|
||||
fetchIntegrations.mockResolvedValueOnce([bucket(5, "Claims")]);
|
||||
fetchIntegrations.mockResolvedValueOnce([]);
|
||||
render(<Integrations />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.connections.types.s3.label"),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("portal.connections.delete"));
|
||||
|
||||
await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5));
|
||||
@@ -115,9 +111,6 @@ describe("Integrations view", () => {
|
||||
);
|
||||
render(<Integrations />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.connections.types.s3.label"),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("portal.connections.delete"));
|
||||
|
||||
expect(
|
||||
|
||||
@@ -2,8 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import SearchRoundedIcon from "@mui/icons-material/SearchRounded";
|
||||
import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded";
|
||||
import { Banner, Button, Skeleton } from "@app/ui";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
type DataTableGroup,
|
||||
EmptyState,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deleteIntegration,
|
||||
@@ -30,12 +37,11 @@ import "@portal/views/Integrations.css";
|
||||
/**
|
||||
* The integrations catalogue: everything Stirling can talk to, in one place.
|
||||
*
|
||||
* Three bands in one list. Connected first — stored connections grouped by
|
||||
* vendor, expandable when a vendor has several (two S3 buckets is normal, not
|
||||
* an error), each instance editable and one click from "add another". Then
|
||||
* Available — the supported vendors, each saying what it works with (sources,
|
||||
* policies, pipelines) so it's obvious whether a vendor feeds documents in or
|
||||
* receives them. Coming-soon source connectors close the list greyed out, so
|
||||
* Three bands, one grouped table. Connected first - stored connections grouped
|
||||
* by vendor (two S3 buckets is normal, not an error), every instance a row you
|
||||
* can edit or remove, with "add another" on the vendor's group header. Then
|
||||
* Available - the supported vendors, each saying what it works with (sources,
|
||||
* policies, pipelines). Coming-soon source connectors close the list so
|
||||
* "do you support X?" is answered honestly instead of hidden.
|
||||
*
|
||||
* Setup itself stays in the shared {@link ConnectionModal}; every entry point
|
||||
@@ -72,6 +78,20 @@ interface TypeGroup {
|
||||
connections: IntegrationConfig[];
|
||||
}
|
||||
|
||||
/** One normalized row across the three bands, so a single grouped table renders
|
||||
* connected instances, available vendors, and coming-soon vendors alike. */
|
||||
type IntegrationRow = {
|
||||
key: string;
|
||||
brandId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
worksWith: WorksWith[];
|
||||
} & (
|
||||
| { kind: "instance"; connection: IntegrationConfig; canManage: boolean }
|
||||
| { kind: "available"; typeId: string }
|
||||
| { kind: "soon" }
|
||||
);
|
||||
|
||||
export function Integrations() {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
@@ -82,13 +102,13 @@ export function Integrations() {
|
||||
>(undefined);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [modal, setModal] = useState<{
|
||||
open: boolean;
|
||||
editing: IntegrationConfig | null;
|
||||
fixedTypeId?: string;
|
||||
}>({ open: false, editing: null });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -192,45 +212,152 @@ export function Integrations() {
|
||||
return counts;
|
||||
}, [catalogue]);
|
||||
|
||||
function toggleExpand(typeId: string) {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(typeId)) next.delete(typeId);
|
||||
else next.add(typeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreate(typeId: string) {
|
||||
const openCreate = useCallback((typeId: string) => {
|
||||
setModal({ open: true, editing: null, fixedTypeId: typeId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
function openEdit(connection: IntegrationConfig) {
|
||||
const openEdit = useCallback((connection: IntegrationConfig) => {
|
||||
setModal({ open: true, editing: connection });
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function remove(connection: IntegrationConfig) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIntegration(connection.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const remove = useCallback(
|
||||
async (connection: IntegrationConfig) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setDeletingId(connection.id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIntegration(connection.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[busy, refresh],
|
||||
);
|
||||
|
||||
const isLoading = connections === null;
|
||||
|
||||
const chip = (kind: WorksWith) => (
|
||||
<span key={kind} className="portal-integrations__chip">
|
||||
{t(`portal.integrations.worksWith.${kind}`)}
|
||||
</span>
|
||||
const worksWithText = useCallback(
|
||||
(list: WorksWith[]) =>
|
||||
list.map((w) => t(`portal.integrations.worksWith.${w}`)).join(", "),
|
||||
[t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DataTableColumn<IntegrationRow>[]>(
|
||||
() => [
|
||||
column.entity({
|
||||
key: "integration",
|
||||
header: t("portal.integrations.table.integration"),
|
||||
icon: (r) => <BrandMark id={r.brandId} size={22} />,
|
||||
primary: (r) => r.title,
|
||||
note: (r) => r.subtitle || undefined,
|
||||
}),
|
||||
column.text({
|
||||
key: "worksWith",
|
||||
header: t("portal.integrations.table.worksWith"),
|
||||
get: (r) => worksWithText(r.worksWith),
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
get: (r) => {
|
||||
if (r.kind === "instance") {
|
||||
return r.canManage
|
||||
? [
|
||||
{
|
||||
label: t("portal.connections.edit"),
|
||||
disabled: busy,
|
||||
onClick: () => openEdit(r.connection),
|
||||
},
|
||||
{
|
||||
label: t("portal.connections.delete"),
|
||||
tone: "danger",
|
||||
loading: busy && deletingId === r.connection.id,
|
||||
disabled: busy,
|
||||
onClick: () => void remove(r.connection),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
if (r.kind === "available") {
|
||||
return [
|
||||
{
|
||||
label: t("portal.integrations.connect"),
|
||||
onClick: () => openCreate(r.typeId),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t, busy, deletingId, remove, openEdit, openCreate, worksWithText],
|
||||
);
|
||||
|
||||
const tableGroups = useMemo<DataTableGroup<IntegrationRow>[]>(() => {
|
||||
const gs: DataTableGroup<IntegrationRow>[] = [];
|
||||
for (const { type, connections: list } of connectedGroups) {
|
||||
gs.push({
|
||||
key: `connected-${type.id}`,
|
||||
title: t(type.labelKey),
|
||||
meta:
|
||||
list.length > 1
|
||||
? t("portal.integrations.connectionCount", { count: list.length })
|
||||
: t("portal.integrations.status.connected"),
|
||||
actions: [
|
||||
{
|
||||
label: t("portal.integrations.connect"),
|
||||
onClick: () => openCreate(type.id),
|
||||
},
|
||||
],
|
||||
rows: list.map((c) => ({
|
||||
kind: "instance" as const,
|
||||
key: `i-${c.id}`,
|
||||
brandId: type.id,
|
||||
title: c.name,
|
||||
subtitle: connectionDetail(c),
|
||||
worksWith: worksWith(type),
|
||||
connection: c,
|
||||
canManage: !!c.canManage,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (availableTypes.length > 0) {
|
||||
gs.push({
|
||||
key: "available",
|
||||
title: t("portal.integrations.availableHeading"),
|
||||
rows: availableTypes.map((type) => ({
|
||||
kind: "available" as const,
|
||||
key: `a-${type.id}`,
|
||||
brandId: type.id,
|
||||
title: t(type.labelKey),
|
||||
subtitle: t(type.descriptionKey),
|
||||
worksWith: worksWith(type),
|
||||
typeId: type.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (comingSoon.length > 0) {
|
||||
gs.push({
|
||||
key: "soon",
|
||||
title: t("portal.integrations.comingSoonHeading"),
|
||||
muted: true,
|
||||
rows: comingSoon.map((entry) => ({
|
||||
kind: "soon" as const,
|
||||
key: `s-${entry.type}`,
|
||||
brandId: entry.type,
|
||||
title: t(entry.labelKey),
|
||||
subtitle: t(entry.descriptionKey),
|
||||
worksWith: ["sources"],
|
||||
})),
|
||||
});
|
||||
}
|
||||
return gs;
|
||||
}, [connectedGroups, availableTypes, comingSoon, t, openCreate]);
|
||||
|
||||
return (
|
||||
<div className="portal-integrations">
|
||||
<header className="portal-integrations__head">
|
||||
@@ -301,187 +428,23 @@ export function Integrations() {
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="portal-integrations__skeleton" aria-hidden>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3.25rem" />
|
||||
))}
|
||||
</div>
|
||||
{!isLoading && tableGroups.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.integrations.noResults.title", "No matches")}
|
||||
description={t(
|
||||
"portal.integrations.noResults.description",
|
||||
"No integrations match your filters. Try a different category or search.",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="portal-surface portal-integrations__table">
|
||||
<div className="portal-integrations__cols" aria-hidden>
|
||||
<span>{t("portal.integrations.table.integration")}</span>
|
||||
<span>{t("portal.integrations.table.worksWith")}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{connectedGroups.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.connectedHeading")} ·{" "}
|
||||
{connectedGroups.length}
|
||||
</div>
|
||||
)}
|
||||
{connectedGroups.map(({ type, connections: list }) => {
|
||||
const open = expanded.has(type.id);
|
||||
return (
|
||||
<div key={type.id} className="portal-integrations__group">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-integrations__row portal-integrations__row--connected"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleExpand(type.id)}
|
||||
>
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={type.id} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{list.length === 1
|
||||
? list[0].name
|
||||
: t("portal.integrations.connectionCount", {
|
||||
count: list.length,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{worksWith(type).map(chip)}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<span className="portal-integrations__status-dot" />
|
||||
{t("portal.integrations.status.connected")}
|
||||
<ExpandMoreRoundedIcon
|
||||
fontSize="inherit"
|
||||
className={
|
||||
"portal-integrations__chevron" +
|
||||
(open ? " is-open" : "")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="portal-integrations__instances">
|
||||
{list.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="portal-integrations__instance"
|
||||
>
|
||||
<span className="portal-integrations__instance-name">
|
||||
{connection.name}
|
||||
</span>
|
||||
<span className="portal-integrations__instance-detail">
|
||||
{connectionDetail(connection)}
|
||||
</span>
|
||||
{connection.canManage && (
|
||||
<span className="portal-integrations__instance-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(connection)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(connection)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="portal-integrations__instance portal-integrations__instance--add">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => openCreate(type.id)}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.integrations.addAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{availableTypes.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.availableHeading")} ·{" "}
|
||||
{availableTypes.length}
|
||||
</div>
|
||||
)}
|
||||
{availableTypes.map((type) => (
|
||||
<div key={type.id} className="portal-integrations__row">
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={type.id} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{t(type.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{worksWith(type).map(chip)}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openCreate(type.id)}
|
||||
>
|
||||
{t("portal.integrations.connect")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{comingSoon.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.comingSoonHeading")} · {comingSoon.length}
|
||||
</div>
|
||||
)}
|
||||
{comingSoon.map((entry) => (
|
||||
<div
|
||||
key={entry.type}
|
||||
className="portal-integrations__row portal-integrations__row--soon"
|
||||
aria-disabled
|
||||
>
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={entry.type} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{t(entry.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{chip("sources")}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<span className="portal-integrations__soon-badge">
|
||||
{t("portal.sources.builder.comingSoon")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DataTable<IntegrationRow>
|
||||
columns={columns}
|
||||
groups={tableGroups}
|
||||
rowKey={(r) => r.key}
|
||||
loading={isLoading}
|
||||
skeletonRows={5}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectionModal
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("Users page (SaaS flavor, end-to-end via SaasTeamController mocks)", ()
|
||||
await screen.findByText("sam.lee@acme.com");
|
||||
const panel = screen
|
||||
.getByText("Pending invitations")
|
||||
.closest("section") as HTMLElement;
|
||||
.closest("table") as HTMLElement;
|
||||
fireEvent.click(within(panel).getByRole("button", { name: "Cancel" }));
|
||||
// Confirm dialog -> DELETE /invitations/{id} -> refetch drops the invite.
|
||||
fireEvent.click(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, EmptyState, Skeleton } from "@app/ui";
|
||||
@@ -71,12 +71,14 @@ export function Users() {
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Scroll to and flash the row for ?member=<id> (deep link from the super
|
||||
// search), once the roster has rendered; then strip the param.
|
||||
// search), once the roster has rendered; then strip the param. Scoped to the
|
||||
// roster so a pending-invitation row sharing the id can't match first.
|
||||
const rosterRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const memberId = searchParams.get("member");
|
||||
if (memberId === null || usersState.loading) return;
|
||||
const row = document.querySelector(
|
||||
`[data-member-id="${CSS.escape(memberId)}"]`,
|
||||
const row = rosterRef.current?.querySelector(
|
||||
`[data-row-key="${CSS.escape(memberId)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "center" });
|
||||
@@ -343,28 +345,30 @@ export function Users() {
|
||||
)}
|
||||
|
||||
{!loading && members.length > 0 && (
|
||||
<UsersDirectory
|
||||
members={members}
|
||||
teams={teams}
|
||||
capabilities={caps}
|
||||
onChangeRole={changeRole}
|
||||
onGrantProcessor={grantProcessor}
|
||||
onRevokeProcessor={revokeProcessor}
|
||||
processorTeamIds={processorTeamIds}
|
||||
onGrantTeamProcessor={grantTeamProcessor}
|
||||
onRevokeTeamProcessor={revokeTeamProcessor}
|
||||
onAddToTeam={(team) => openInvite(team.id)}
|
||||
onResetPassword={setResetPwMember}
|
||||
onMoveToTeam={setMoveMember}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onUnlock={unlock}
|
||||
onDisableMfa={disableMfa}
|
||||
onRemove={removeUser}
|
||||
onRenameTeam={(team) =>
|
||||
setRenameTarget({ id: team.id, name: team.name })
|
||||
}
|
||||
onDeleteTeam={deleteTeamAction}
|
||||
/>
|
||||
<div ref={rosterRef}>
|
||||
<UsersDirectory
|
||||
members={members}
|
||||
teams={teams}
|
||||
capabilities={caps}
|
||||
onChangeRole={changeRole}
|
||||
onGrantProcessor={grantProcessor}
|
||||
onRevokeProcessor={revokeProcessor}
|
||||
processorTeamIds={processorTeamIds}
|
||||
onGrantTeamProcessor={grantTeamProcessor}
|
||||
onRevokeTeamProcessor={revokeTeamProcessor}
|
||||
onAddToTeam={(team) => openInvite(team.id)}
|
||||
onResetPassword={setResetPwMember}
|
||||
onMoveToTeam={setMoveMember}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onUnlock={unlock}
|
||||
onDisableMfa={disableMfa}
|
||||
onRemove={removeUser}
|
||||
onRenameTeam={(team) =>
|
||||
setRenameTarget({ id: team.id, name: team.name })
|
||||
}
|
||||
onDeleteTeam={deleteTeamAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InviteMemberModal
|
||||
|
||||
Generated
+65
@@ -50,6 +50,7 @@
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "2.7.0",
|
||||
@@ -4936,6 +4937,44 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
|
||||
"integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/store": "0.11.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz",
|
||||
"integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/react-store": "^0.11.0",
|
||||
"@tanstack/table-core": "9.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
@@ -4953,6 +4992,32 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
|
||||
"integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz",
|
||||
"integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/store": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "2.7.0",
|
||||
|
||||
Reference in New Issue
Block a user