diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4d5e271103..99d68c35de 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 3caf240da4..dffd6beaba 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -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); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index aa0a1b38cc..554baddcca 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -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; diff --git a/frontend/editor/src/core/ui/DataTable.css b/frontend/editor/src/core/ui/DataTable.css new file mode 100644 index 0000000000..c7572efa90 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.css @@ -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; +} diff --git a/frontend/editor/src/core/ui/DataTable.stories.tsx b/frontend/editor/src/core/ui/DataTable.stories.tsx new file mode 100644 index 0000000000..630537ace9 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.stories.tsx @@ -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[] = [ + 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[] = [ + 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 = { + title: "Compound/DataTable", + component: DataTable, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Columns come from the `column` vocabulary; call-sites never style a cell. */ +export const Basic: Story = { + render: () => ( + columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> + ), +}; + +/** Sorting is opt-in per column (`sortable: true`); click a header. */ +export const Sortable: Story = { + render: () => ( + + 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(null); + return ( + <> +

+ {clicked + ? `Clicked: ${clicked.name} (${clicked.code})` + : "Click a row to fire onRowClick."} +

+ + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + onRowClick={setClicked} + rowAffordance="chevron" + /> + + ); + } + return ; + }, +}; + +/** A trailing action column (icon-only kebab), locked to the design system. */ +export const WithActions: Story = { + render: () => ( + + 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: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + loading + /> + ), +}; + +/** Standardized empty slot. */ +export const Empty: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + empty="No regions deployed yet." + /> + ), +}; + +/** Standardized error slot (announced as an alert). */ +export const ErrorState: Story = { + render: () => ( + + 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: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + toolbar={ + <> + Regions + + + + } + /> + ), +}; + +/** The one look choice: the `compact` variant. */ +export const Compact: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + variant="compact" + /> + ), +}; diff --git a/frontend/editor/src/core/ui/DataTable.tsx b/frontend/editor/src/core/ui/DataTable.tsx new file mode 100644 index 0000000000..ccb5578016 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.tsx @@ -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 { + 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 { + /** Columns built with the `column` vocabulary — never raw JSX. */ + columns: DataTableColumn[]; + /** Flat rows. Provide this OR `groups`, not both. */ + rows?: T[]; + /** Grouped rows with section headers. Takes precedence over `rows`. */ + groups?: DataTableGroup[]; + 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 ( + + + + ); +} + +function SortGlyph() { + return ( + + + + ); +} + +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({ + 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) { + const { t } = useTranslation(); + const [sorting, setSorting] = useState( + defaultSort + ? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }] + : [], + ); + const [openGroups, setOpenGroups] = useState>(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[]>(() => { + if (!showChevron) return columns; + return [ + ...columns, + { + key: CHEVRON_COLUMN_KEY, + header: "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + renderCell: (row) => + (isRowInteractive?.(row) ?? true) ? ( + + + + ) : null, + }, + ]; + }, [columns, showChevron, isRowInteractive]); + + const tanstackColumns = useMemo[]>(() => { + const helper = createColumnHelper(); + 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) => { + 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) => ( + + {effectiveColumns.map((c) => ( + + + + ))} + + )); + } else if (error != null) { + body = ( + + + {error} + + + ); + } else if (noRows) { + const isNode = typeof empty === "object" && empty !== null; + body = ( + + + {empty ?? "No data"} + + + ); + } 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 = ( + + +
+
+ {g.title} + {g.meta && ( + {g.meta} + )} +
+ {g.actions && + g.actions.length > 0 && + renderCellActions(g.actions)} +
+ + + ); + const rowEls = shown.map((row) => ( + + {effectiveColumns.map((c) => ( + + {c.renderCell(row)} + + ))} + + )); + const moreEl = overflow ? ( + + + + + + ) : null; + return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls]; + }); + } else { + body = table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => { + const meta = cell.column.columnDef.meta; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )); + } + + return ( +
+
+ {toolbar &&
{toolbar}
} +
+ + {caption && ( + + )} + + {table.getHeaderGroups().map((headerGroup) => ( + + {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 ( + + ); + })} + + ))} + + {body} +
{caption}
+ {canSort ? ( + + ) : meta?.srHeader ? ( + + {meta.srHeader} + + ) : ( + label + )} +
+
+
+
+ ); +} + +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(" "); +} diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 8b84af29f1..d71026d881 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -4,28 +4,20 @@ } .sui-dd__menu { - position: absolute; - top: calc(100% + var(--space-1)); + /* Positioned (fixed, portaled to ) 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; diff --git a/frontend/editor/src/core/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx index bf481c9ea5..25019d7c3c 100644 --- a/frontend/editor/src/core/ui/Dropdown.tsx +++ b/frontend/editor/src/core/ui/Dropdown.tsx @@ -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; + /** The portaled menu element, so click-outside can exclude it. */ + menuRef: React.RefObject; menuId: string; align: Alignment; } @@ -68,15 +72,20 @@ function Root({ const triggerRef = useRef(null); const containerRef = useRef(null); + const menuRef = useRef(null); const menuId = useId(); - // Click-outside + Escape close. + // Click-outside + Escape close. The menu is portaled to , 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( - () => ({ 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 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( + , + document.body, ); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css deleted file mode 100644 index fa89fd04d6..0000000000 --- a/frontend/editor/src/core/ui/Table.css +++ /dev/null @@ -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); -} diff --git a/frontend/editor/src/core/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx deleted file mode 100644 index 2e7e0f64f1..0000000000 --- a/frontend/editor/src/core/ui/Table.stories.tsx +++ /dev/null @@ -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[] = [ - { key: "name", header: "Region", render: (r) => r.name }, - { - key: "code", - header: "Code", - render: (r) => ( - {r.code} - ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {r.status} - - ), - }, - { - 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 = { - title: "Compound/Table", - component: Table, - tags: ["autodocs"], - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ -export const Basic: Story = { - render: () => ( - columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> - ), -}; - -/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ -export const Interactive: Story = { - render: () => ( - - columns={COLUMNS} - rows={REGIONS} - rowKey={(r) => r.id} - onRowClick={() => {}} - /> - ), -}; - -/** Empty body slot. */ -export const Empty: Story = { - render: () => ( - - columns={COLUMNS} - rows={[]} - rowKey={(r) => r.id} - empty="No regions deployed yet." - /> - ), -}; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx deleted file mode 100644 index 417df3d6c1..0000000000 --- a/frontend/editor/src/core/ui/Table.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Surface.css"; -import "@app/ui/Table.css"; - -export interface TableColumn { - /** 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 { - columns: TableColumn[]; - 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 } 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({ - columns, - rows, - rowKey, - onRowClick, - isRowInteractive, - rowsContainControls = false, - empty, - className, -}: TableProps) { - const interactive = Boolean(onRowClick); - return ( -
- - - - {columns.map((c) => ( - - ))} - - - - {rows.length === 0 ? ( - - - - ) : ( - 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 ( - 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) => ( - - ))} - - ); - }) - )} - -
- {c.headerHidden ? ( - {c.header} - ) : ( - c.header - )} -
- {empty ?? "No data"} -
- {c.render(row)} -
-
- ); -} diff --git a/frontend/editor/src/core/ui/dataTableColumns.tsx b/frontend/editor/src/core/ui/dataTableColumns.tsx new file mode 100644 index 0000000000..df31dc384a --- /dev/null +++ b/frontend/editor/src/core/ui/dataTableColumns.tsx @@ -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 { + 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 ( + + + + + + ); +} + +/** 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 ( +
e.stopPropagation()}> + {actions.map((a) => + a.menu ? ( + + + + + + {a.menu.map((m) => ( + + {m.dividerBefore && } + + {m.label} + + + ))} + + + ) : ( + + ), + )} +
+ ); +} + +/** 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( + o: Common, + extra: Pick, "align" | "nowrap" | "fit" | "renderCell"> & { + sortValue?: (row: T) => SortValue; + sortFn?: DataTableSortFn; + interactive?: boolean; + }, +): DataTableColumn { + 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( + 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 { + return base(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 ? ( + + {label}: {o.get(r)} + + ) : ( + {o.get(r)} + ); + }, + }); +} + +function mono( + o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => {o.get(r)}, + }); +} + +function muted( + 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 { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "alphanumeric", + renderCell: (r) => ( + + {o.get(r) || (o.placeholder ?? "-")} + + ), + }); +} + +function number( + 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 { + return base(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 ( + + {o.placeholder ?? "-"} + + ); + } + return ( + + {o.format ? o.format(n, r) : String(n)} + + ); + }, + }); +} + +function badge( + o: Common & { + get: (row: T) => { tone: StatusTone; label: string }; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(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 ( + + {b.label} + + ); + }, + }); +} + +/** + * 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( + o: Common & { get: (row: T) => CellLabel[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: (r) => o.get(r)[0]?.label ?? undefined, + sortFn: "alphanumeric", + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }); +} + +/** + * 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( + o: Common & { get: (row: T) => CellCap[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((c) => ( + + {c.label} + + ))} +
+ ), + }); +} + +function entity( + 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 { + return base(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 ( +
+ {icon != null && ( + + {icon} + + )} +
+ + {o.primary(r)} + {suffix && ( + {suffix} + )} + + {note && {note}} +
+
+ ); + }, + }); +} + +function actions(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellAction[]; +}): DataTableColumn { + 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( + 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 { + return base(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 ( +
+ + + + {shown} +
+ ); + }, + }); +} + +function links(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellLink[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }; +} + +function select(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 { + 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 ( +
+ onChangeRole(m, (value ?? m.role) as RoleId)} - /> -
- )} + const groups = useMemo[]>(() => { + 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)} - - ); - } - - /** 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 && ( - - )} - - ); - } + const gs: DataTableGroup[] = []; + 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 ( -
- {/* Organization (a single-org deployment only; SaaS has no org). */} - {capabilities.orgGroup && dir.organization.length > 0 && ( -
-
-
- {t("users.group.org", "Organization")} - - {t( - "users.group.orgDesc", - "Owners with org-wide authority and policy approval", - )} - -
- - {t("users.group.owners", "{{count}} owner", { - count: dir.organization.length, - })} - -
- {renderMembers(dir.organization, "org")} -
- )} - - {/* Teams */} - {dir.teams.map((team) => ( -
-
-
- - {t("users.group.team", "{{name}} team", { name: team.name })} - - - {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), - })}`} - -
-
- - {teamKebabHasItems(team) && ( - - - - - - {capabilities.manageGrants && - (processorTeamIds.has(team.id) ? ( - onRevokeTeamProcessor(team)}> - {t( - "users.team.revokeProcessor", - "Revoke Processor from team", - )} - - ) : ( - onGrantTeamProcessor(team)}> - {t( - "users.team.grantProcessor", - "Grant Processor to team", - )} - - ))} - {!isManagedTeam(team) && - (capabilities.renameTeam || capabilities.deleteTeam) && ( - <> - {capabilities.manageGrants && } - {capabilities.renameTeam && ( - onRenameTeam(team)}> - {t("users.action.rename", "Rename team")} - - )} - {capabilities.deleteTeam && ( - onDeleteTeam(team)} - > - {t("users.action.deleteTeam", "Delete team")} - - )} - - )} - - - )} -
-
- {renderMembers(team.members, `team-${team.id}`)} -
- ))} - - {/* Guests (parked in the live app; shown when showGuests is set). */} - {showGuests && dir.guests.length > 0 && ( -
-
-
- {t("users.group.guests", "Guests")} - - {t( - "users.group.guestsDesc", - "External collaborators, scoped to what you shared. Editor only.", - )} - -
- - {t("users.group.guestCount", "{{count}} guest", { - count: dir.guests.length, - })} - -
- {renderMembers(dir.guests, "guests")} -
- )} -
+ + 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"), + }} + /> ); } diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index c5e96d8666..8c4e684bfa 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -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(); - // 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(); - 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(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); expect( diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 993583a364..793c21f880 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -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( @@ -82,13 +102,13 @@ export function Integrations() { >(undefined); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); - const [expanded, setExpanded] = useState>(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(null); const [error, setError] = useState(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) => ( - - {t(`portal.integrations.worksWith.${kind}`)} - + const worksWithText = useCallback( + (list: WorksWith[]) => + list.map((w) => t(`portal.integrations.worksWith.${w}`)).join(", "), + [t], ); + const columns = useMemo[]>( + () => [ + column.entity({ + key: "integration", + header: t("portal.integrations.table.integration"), + icon: (r) => , + 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[]>(() => { + const gs: DataTableGroup[] = []; + 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 (
@@ -301,187 +428,23 @@ export function Integrations() { {error && } - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
+ {!isLoading && tableGroups.length === 0 ? ( + ) : ( -
-
- {t("portal.integrations.table.integration")} - {t("portal.integrations.table.worksWith")} - -
- - {connectedGroups.length > 0 && ( -
- {t("portal.integrations.connectedHeading")} ·{" "} - {connectedGroups.length} -
- )} - {connectedGroups.map(({ type, connections: list }) => { - const open = expanded.has(type.id); - return ( -
- - {open && ( -
- {list.map((connection) => ( -
- - {connection.name} - - - {connectionDetail(connection)} - - {connection.canManage && ( - - - - - )} -
- ))} -
- -
-
- )} -
- ); - })} - - {availableTypes.length > 0 && ( -
- {t("portal.integrations.availableHeading")} ·{" "} - {availableTypes.length} -
- )} - {availableTypes.map((type) => ( -
- - - - - {t(type.labelKey)} - - - {t(type.descriptionKey)} - - - - - {worksWith(type).map(chip)} - - - - -
- ))} - - {comingSoon.length > 0 && ( -
- {t("portal.integrations.comingSoonHeading")} · {comingSoon.length} -
- )} - {comingSoon.map((entry) => ( -
- - - - - {t(entry.labelKey)} - - - {t(entry.descriptionKey)} - - - - - {chip("sources")} - - - - {t("portal.sources.builder.comingSoon")} - - -
- ))} -
+ + columns={columns} + groups={tableGroups} + rowKey={(r) => r.key} + loading={isLoading} + skeletonRows={5} + /> )} DELETE /invitations/{id} -> refetch drops the invite. fireEvent.click( diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 535bf3e627..79c6c11611 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -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= (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(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 && ( - 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} - /> +
+ 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} + /> +
)} =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", diff --git a/frontend/package.json b/frontend/package.json index 3fd1f8e106..90a0e10b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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",