@plantops/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/data/data-table.d.ts +41 -0
- package/dist/data/data-table.d.ts.map +1 -0
- package/dist/data/data-table.js +31 -0
- package/dist/data/scope-tree-select.d.ts +51 -0
- package/dist/data/scope-tree-select.d.ts.map +1 -0
- package/dist/data/scope-tree-select.js +59 -0
- package/dist/data/scope-tree.d.ts +127 -0
- package/dist/data/scope-tree.d.ts.map +1 -0
- package/dist/data/scope-tree.js +163 -0
- package/dist/data/status-tag.d.ts +14 -0
- package/dist/data/status-tag.d.ts.map +1 -0
- package/dist/data/status-tag.js +50 -0
- package/dist/feedback/error-copy.d.ts +38 -0
- package/dist/feedback/error-copy.d.ts.map +1 -0
- package/dist/feedback/error-copy.js +97 -0
- package/dist/feedback/page-header.d.ts +17 -0
- package/dist/feedback/page-header.d.ts.map +1 -0
- package/dist/feedback/page-header.js +23 -0
- package/dist/feedback/state-panels.d.ts +50 -0
- package/dist/feedback/state-panels.d.ts.map +1 -0
- package/dist/feedback/state-panels.js +46 -0
- package/dist/forms/auth-layout.d.ts +12 -0
- package/dist/forms/auth-layout.d.ts.map +1 -0
- package/dist/forms/auth-layout.js +28 -0
- package/dist/forms/credentials-form.d.ts +34 -0
- package/dist/forms/credentials-form.d.ts.map +1 -0
- package/dist/forms/credentials-form.js +37 -0
- package/dist/icons/icon-registry.d.ts +25 -0
- package/dist/icons/icon-registry.d.ts.map +1 -0
- package/dist/icons/icon-registry.js +102 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/layout/app-shell.d.ts +23 -0
- package/dist/layout/app-shell.d.ts.map +1 -0
- package/dist/layout/app-shell.js +84 -0
- package/dist/layout/brand.d.ts +10 -0
- package/dist/layout/brand.d.ts.map +1 -0
- package/dist/layout/brand.js +48 -0
- package/dist/layout/nav-menu.d.ts +30 -0
- package/dist/layout/nav-menu.d.ts.map +1 -0
- package/dist/layout/nav-menu.js +75 -0
- package/dist/layout/nav-tree.d.ts +67 -0
- package/dist/layout/nav-tree.d.ts.map +1 -0
- package/dist/layout/nav-tree.js +101 -0
- package/dist/layout/user-menu.d.ts +20 -0
- package/dist/layout/user-menu.d.ts.map +1 -0
- package/dist/layout/user-menu.js +75 -0
- package/dist/theme/color-mode.d.ts +45 -0
- package/dist/theme/color-mode.d.ts.map +1 -0
- package/dist/theme/color-mode.js +81 -0
- package/dist/theme/theme-provider.d.ts +17 -0
- package/dist/theme/theme-provider.d.ts.map +1 -0
- package/dist/theme/theme-provider.js +65 -0
- package/dist/theme/theme.d.ts +23 -0
- package/dist/theme/theme.d.ts.map +1 -0
- package/dist/theme/theme.js +141 -0
- package/dist/theme/tokens.d.ts +130 -0
- package/dist/theme/tokens.d.ts.map +1 -0
- package/dist/theme/tokens.js +127 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @plantops/ui
|
|
2
|
+
|
|
3
|
+
The shared React presentation layer for every PlantOps console (Doc 08 §2).
|
|
4
|
+
|
|
5
|
+
Ant Design 6 supplies the components; this library supplies the *product* — the
|
|
6
|
+
design language, the theme built from it, the shell every console renders
|
|
7
|
+
inside, the icon-key registry Doc 05 §7 requires, and the handful of patterns
|
|
8
|
+
that must not be reinvented per screen.
|
|
9
|
+
|
|
10
|
+
## The rule that keeps it reusable
|
|
11
|
+
|
|
12
|
+
**Nothing here calls the IAM.** It depends on `@plantops/contracts` for types and
|
|
13
|
+
on nothing else in the workspace, enforced by the `scope:ui` boundary in the root
|
|
14
|
+
ESLint config: every component takes data and callbacks and returns markup. The
|
|
15
|
+
stateful half — a client, tokens, grants, navigation fetching — is
|
|
16
|
+
[`@plantops/web-kit`](../web-kit), which depends on this.
|
|
17
|
+
|
|
18
|
+
That split is what makes the gatepass and visitor consoles cheap. They mount the
|
|
19
|
+
same provider and shell, render the same `<NavMenu>` from their own
|
|
20
|
+
`/iam/navigation` response, and inherit the product's appearance without
|
|
21
|
+
inheriting the IAM's screens.
|
|
22
|
+
|
|
23
|
+
## What is in it
|
|
24
|
+
|
|
25
|
+
| Area | Exports |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `theme/` | `PlantOpsThemeProvider`, `plantOpsTheme`, `useColorMode`, and the raw tokens (`palette`, `neutral`, `spacing`, `radius`, `layout`, `typography`) |
|
|
28
|
+
| `layout/` | `AppShell`, `NavMenu`, `Brand`, `UserMenu`, and the pure `nav-tree` helpers (`firstNavRoute`, `navSelectionForPath`, `flattenNavRoutes`) |
|
|
29
|
+
| `icons/` | `NavIcon`, `iconForKey`, `knownIconKeys` — the `nav_node.icon` → icon-set map |
|
|
30
|
+
| `feedback/` | `PageHeader`, `ScreenLoading`, `ScreenEmpty`, `ScreenError`, and `errorCopyFor` — one sentence per `IamErrorCode` |
|
|
31
|
+
| `forms/` | `AuthLayout`, `CredentialsForm` |
|
|
32
|
+
| `data/` | `DataTable` (bound to the Doc 06 §1 pagination envelope), `StatusTag` |
|
|
33
|
+
|
|
34
|
+
## Using it
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { AppShell, Brand, NavMenu, PageHeader, PlantOpsThemeProvider } from '@plantops/ui';
|
|
38
|
+
|
|
39
|
+
<PlantOpsThemeProvider>
|
|
40
|
+
<AppShell brand={<Brand product="Gatepass" />} nav={<NavMenu tree={tree} … />}>
|
|
41
|
+
<PageHeader title="Passes" />
|
|
42
|
+
</AppShell>
|
|
43
|
+
</PlantOpsThemeProvider>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Consumed as TypeScript source: `package.json` points `main` at `src/index.ts`, so
|
|
47
|
+
a Next.js app lists it in `transpilePackages` and picks up changes without a
|
|
48
|
+
separate library build.
|
|
49
|
+
|
|
50
|
+
## Conventions worth knowing before adding a component
|
|
51
|
+
|
|
52
|
+
- **No hex literals in components.** Colours come from `theme/tokens.ts` or from
|
|
53
|
+
antd's CSS variables (`var(--ant-color-primary)`). A literal that is right in
|
|
54
|
+
light mode is wrong in dark mode and cannot know which is showing.
|
|
55
|
+
- **Red, amber and green are reserved for status.** PlantOps administers plants;
|
|
56
|
+
those three already mean something on a plant floor, which is why the brand
|
|
57
|
+
colour is a deep teal. See the header of `theme/tokens.ts`.
|
|
58
|
+
- **Copy is part of the design language.** What a `403` says to a person lives in
|
|
59
|
+
`feedback/error-copy.ts`, not in each console, so the gatepass console cannot
|
|
60
|
+
invent a second wording — and so Doc 06 §2's "a denial never reveals whether
|
|
61
|
+
the target exists in another tenant" is enforceable by a test.
|
|
62
|
+
- **`'use client'` on anything with state or an event handler.** These libraries
|
|
63
|
+
are consumed by Next.js App Router apps.
|
|
64
|
+
|
|
65
|
+
## Tests
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npx nx test @plantops/ui
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
jsdom, with the antd-shaped browser APIs stubbed in `src/test-setup.ts`. antd 6
|
|
72
|
+
ships ESM inside its CommonJS build, so `transformIgnorePatterns` in
|
|
73
|
+
`jest.config.cts` transforms `antd`, `@ant-design/*` and `@rc-component/*` rather
|
|
74
|
+
than skipping them.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An antd `Table` wired to the IAM's pagination envelope (Doc 06 §1).
|
|
3
|
+
*
|
|
4
|
+
* Every list screen in every console — users, roles, clients, applications,
|
|
5
|
+
* bindings, service accounts, audit — receives `{ data, page, limit, total }`
|
|
6
|
+
* and has to turn it into a table with server-side paging. Doing that by hand
|
|
7
|
+
* per screen is where the same three mistakes appear: reading `data.length` as
|
|
8
|
+
* the total (so the pager shows one page of a thousand rows), losing the page
|
|
9
|
+
* on a refetch, and rendering an empty table where a "no results" explanation
|
|
10
|
+
* belongs.
|
|
11
|
+
*
|
|
12
|
+
* The component is generic over the row type and passes antd's `columns`
|
|
13
|
+
* through untouched, so it constrains nothing about how a screen looks — only
|
|
14
|
+
* about how it talks to the API.
|
|
15
|
+
*/
|
|
16
|
+
import type { Paginated } from '@plantops/contracts';
|
|
17
|
+
import { type TableProps } from 'antd';
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
export interface DataTableQuery {
|
|
20
|
+
page: number;
|
|
21
|
+
limit: number;
|
|
22
|
+
}
|
|
23
|
+
export interface DataTableProps<T> {
|
|
24
|
+
/** The envelope. `undefined` while the first page is in flight. */
|
|
25
|
+
result: Paginated<T> | undefined;
|
|
26
|
+
columns: TableProps<T>['columns'];
|
|
27
|
+
rowKey: (row: T) => string;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
/** Fired when the pager moves. The caller refetches and passes the result back. */
|
|
30
|
+
onQueryChange?: (query: DataTableQuery) => void;
|
|
31
|
+
/** Shown instead of the table body when the result is empty. */
|
|
32
|
+
empty?: React.ReactNode;
|
|
33
|
+
/** Replaces the whole table — for a failed load. */
|
|
34
|
+
error?: React.ReactNode;
|
|
35
|
+
size?: TableProps<T>['size'];
|
|
36
|
+
onRowClick?: (row: T) => void;
|
|
37
|
+
/** Selection, expansion and the rest of antd's surface, when a screen needs it. */
|
|
38
|
+
tableProps?: Omit<TableProps<T>, 'columns' | 'dataSource' | 'rowKey' | 'loading' | 'pagination' | 'onChange'>;
|
|
39
|
+
}
|
|
40
|
+
export declare function DataTable<T>({ result, columns, rowKey, loading, onQueryChange, empty, error, size, onRowClick, tableProps, }: DataTableProps<T>): React.ReactNode;
|
|
41
|
+
//# sourceMappingURL=data-table.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-table.d.ts","sourceRoot":"","sources":["../../src/data/data-table.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,mEAAmE;IACnE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IACjC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mFAAmF;IACnF,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IAChD,gEAAgE;IAChE,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,oDAAoD;IACpD,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAC7B,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,mFAAmF;IACnF,UAAU,CAAC,EAAE,IAAI,CACf,UAAU,CAAC,CAAC,CAAC,EACb,SAAS,GAAG,YAAY,GAAG,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,UAAU,CAC5E,CAAC;CACH;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,EAC3B,MAAM,EACN,OAAO,EACP,MAAM,EACN,OAAe,EACf,aAAa,EACb,KAAK,EACL,KAAK,EACL,IAAe,EACf,UAAU,EACV,UAAU,GACX,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,SAAS,CA4CrC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { MAX_PAGE_SIZE } from '@plantops/contracts';
|
|
4
|
+
import { Table } from 'antd';
|
|
5
|
+
import { ScreenEmpty } from '../feedback/state-panels';
|
|
6
|
+
export function DataTable({ result, columns, rowKey, loading = false, onQueryChange, empty, error, size = 'middle', onRowClick, tableProps, }) {
|
|
7
|
+
if (error !== undefined)
|
|
8
|
+
return error;
|
|
9
|
+
const rows = result?.data ?? [];
|
|
10
|
+
return (_jsx(Table, { ...tableProps, columns: columns, dataSource: rows, rowKey: rowKey, loading: loading, size: size, locale: {
|
|
11
|
+
emptyText: loading ? ' ' : (empty ?? _jsx(ScreenEmpty, {})),
|
|
12
|
+
}, onRow: onRowClick === undefined
|
|
13
|
+
? tableProps?.onRow
|
|
14
|
+
: (row) => ({ onClick: () => onRowClick(row), style: { cursor: 'pointer' } }), pagination: result === undefined
|
|
15
|
+
? false
|
|
16
|
+
: {
|
|
17
|
+
current: result.page,
|
|
18
|
+
pageSize: result.limit,
|
|
19
|
+
total: result.total,
|
|
20
|
+
// The server refuses anything above MAX_PAGE_SIZE (Doc 06 §1), so
|
|
21
|
+
// offering a larger option would be offering a 400.
|
|
22
|
+
pageSizeOptions: [10, 25, 50, MAX_PAGE_SIZE].map(String),
|
|
23
|
+
showSizeChanger: true,
|
|
24
|
+
showTotal: (total, [from, to]) => `${from}–${to} of ${total}`,
|
|
25
|
+
}, onChange: (pagination) => {
|
|
26
|
+
onQueryChange?.({
|
|
27
|
+
page: pagination.current ?? 1,
|
|
28
|
+
limit: pagination.pageSize ?? (result?.limit ?? 25),
|
|
29
|
+
});
|
|
30
|
+
}, scroll: { x: 'max-content' } }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing a node of the org tree, and saying which kind a node is.
|
|
3
|
+
*
|
|
4
|
+
* Both live in `@plantops/ui` because both are needed twice, by screens that
|
|
5
|
+
* must not disagree: Session 31's editor builds the structure and Session 35's
|
|
6
|
+
* access screen grants against it. Doc 09 §3.4 is explicit that the scope picker
|
|
7
|
+
* on the grant screen "shows the tree", so that the admin sees exactly where
|
|
8
|
+
* they are granting — a flat select of node names would hide the one thing that
|
|
9
|
+
* decides how far a grant reaches.
|
|
10
|
+
*
|
|
11
|
+
* Neither component fetches anything. They take the tree and a callback, which
|
|
12
|
+
* is what keeps `scope:ui` free of the IAM client (root `eslint.config.mjs`).
|
|
13
|
+
*/
|
|
14
|
+
import type { ScopeNodeDTO, ScopeNodeKind } from '@plantops/contracts';
|
|
15
|
+
import * as React from 'react';
|
|
16
|
+
/** The tag colour for any kind, suggested or tenant-supplied. */
|
|
17
|
+
export declare function scopeKindColor(kind: ScopeNodeKind): string;
|
|
18
|
+
export interface ScopeKindTagProps {
|
|
19
|
+
kind: ScopeNodeKind;
|
|
20
|
+
}
|
|
21
|
+
export declare function ScopeKindTag({ kind }: ScopeKindTagProps): React.ReactElement;
|
|
22
|
+
export interface ScopeTreeSelectProps {
|
|
23
|
+
/** Roots of the caller's tree, as `GET /iam/scopes` returns them. */
|
|
24
|
+
tree: readonly ScopeNodeDTO[];
|
|
25
|
+
/** The chosen node id, or `null` for nothing chosen. */
|
|
26
|
+
value: string | null;
|
|
27
|
+
onChange: (id: string | null) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Nodes the operator may not choose. They stay in the tree, greyed out and
|
|
30
|
+
* expandable: the node they *can* choose is often beneath one they cannot,
|
|
31
|
+
* and hiding a branch would make the tree lie about the organisation.
|
|
32
|
+
*/
|
|
33
|
+
isDisabled?: (node: ScopeNodeDTO) => boolean;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Rendered when the tenant has no tree at all. */
|
|
37
|
+
notFoundContent?: React.ReactNode;
|
|
38
|
+
allowClear?: boolean;
|
|
39
|
+
style?: React.CSSProperties;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A tree picker over the org structure.
|
|
43
|
+
*
|
|
44
|
+
* `treeDefaultExpandAll` because an org tree is tens of nodes and a picker that
|
|
45
|
+
* opens collapsed makes the operator hunt for a plant they can already name.
|
|
46
|
+
* `treeNodeFilterProp="title"` so typing filters by the display name, which is
|
|
47
|
+
* the only part of a node a person knows — the `path` is id-derived labels
|
|
48
|
+
* (Doc 01 §3.5) and searching it would match nothing anyone typed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function ScopeTreeSelect({ tree, value, onChange, isDisabled, placeholder, disabled, notFoundContent, allowClear, style, }: ScopeTreeSelectProps): React.ReactElement;
|
|
51
|
+
//# sourceMappingURL=scope-tree-select.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope-tree-select.d.ts","sourceRoot":"","sources":["../../src/data/scope-tree-select.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEvE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAyC/B,iEAAiE;AACjE,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAE1D;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;CACrB;AAED,wBAAgB,YAAY,CAAC,EAAE,IAAI,EAAE,EAAE,iBAAiB,GAAG,KAAK,CAAC,YAAY,CAM5E;AAED,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;IAC9B,wDAAwD;IACxD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,eAAe,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAClC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAC9B,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,UAAU,EACV,WAAyC,EACzC,QAAgB,EAChB,eAAe,EACf,UAAiB,EACjB,KAAK,GACN,EAAE,oBAAoB,GAAG,KAAK,CAAC,YAAY,CAqB3C"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Tag, TreeSelect } from 'antd';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { scopeKindLabel, scopeTreeData } from './scope-tree';
|
|
6
|
+
/**
|
|
7
|
+
* The four kinds, told apart at a glance (Doc 09 §3.1: "kinds rendered
|
|
8
|
+
* distinctly").
|
|
9
|
+
*
|
|
10
|
+
* Colour carries the nesting rather than the meaning — the sequence runs from
|
|
11
|
+
* the widest scope to the narrowest, so a tree reads as a gradient inward and an
|
|
12
|
+
* out-of-place kind is visible without reading the label. Nothing enforces that
|
|
13
|
+
* nesting (Doc 01 §3.5 pins no kind to a depth), which is exactly why seeing it
|
|
14
|
+
* is useful.
|
|
15
|
+
*/
|
|
16
|
+
const KIND_COLOR = {
|
|
17
|
+
group: 'purple',
|
|
18
|
+
plant: 'blue',
|
|
19
|
+
department: 'cyan',
|
|
20
|
+
gate: 'green',
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Colours a tenant's own kind labels can land on.
|
|
24
|
+
*
|
|
25
|
+
* The gradient argument above only holds for the suggested four, which nest in
|
|
26
|
+
* a known order. A tenant vocabulary has no order this component can know, so
|
|
27
|
+
* the weaker invariant is the one worth keeping: **distinct kinds look
|
|
28
|
+
* distinct**. Assignment is a hash of the label, so it is stable across
|
|
29
|
+
* renders, across sessions and across users looking at the same tree — which a
|
|
30
|
+
* palette handed out in first-seen order would not be.
|
|
31
|
+
*/
|
|
32
|
+
const FALLBACK_COLORS = ['magenta', 'volcano', 'orange', 'gold', 'lime', 'geekblue'];
|
|
33
|
+
function fallbackColor(kind) {
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (let index = 0; index < kind.length; index += 1) {
|
|
36
|
+
hash = (hash * 31 + kind.charCodeAt(index)) | 0;
|
|
37
|
+
}
|
|
38
|
+
return FALLBACK_COLORS[Math.abs(hash) % FALLBACK_COLORS.length] ?? 'default';
|
|
39
|
+
}
|
|
40
|
+
/** The tag colour for any kind, suggested or tenant-supplied. */
|
|
41
|
+
export function scopeKindColor(kind) {
|
|
42
|
+
return KIND_COLOR[kind] ?? fallbackColor(kind);
|
|
43
|
+
}
|
|
44
|
+
export function ScopeKindTag({ kind }) {
|
|
45
|
+
return (_jsx(Tag, { color: scopeKindColor(kind), style: { marginInlineEnd: 0 }, children: scopeKindLabel(kind) }));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A tree picker over the org structure.
|
|
49
|
+
*
|
|
50
|
+
* `treeDefaultExpandAll` because an org tree is tens of nodes and a picker that
|
|
51
|
+
* opens collapsed makes the operator hunt for a plant they can already name.
|
|
52
|
+
* `treeNodeFilterProp="title"` so typing filters by the display name, which is
|
|
53
|
+
* the only part of a node a person knows — the `path` is id-derived labels
|
|
54
|
+
* (Doc 01 §3.5) and searching it would match nothing anyone typed.
|
|
55
|
+
*/
|
|
56
|
+
export function ScopeTreeSelect({ tree, value, onChange, isDisabled, placeholder = 'Choose where this applies', disabled = false, notFoundContent, allowClear = true, style, }) {
|
|
57
|
+
const treeData = React.useMemo(() => scopeTreeData(tree, { isDisabled }), [tree, isDisabled]);
|
|
58
|
+
return (_jsx(TreeSelect, { value: value ?? undefined, onChange: (next) => onChange(next ?? null), treeData: treeData, disabled: disabled, allowClear: allowClear, showSearch: true, treeNodeFilterProp: "title", treeDefaultExpandAll: true, placeholder: placeholder, notFoundContent: notFoundContent, style: { width: '100%', ...style } }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions over the `ScopeNodeDTO` tree `GET /iam/scopes` returns — the
|
|
3
|
+
* WHERE dimension of every grant (Doc 01 §3.5, Doc 06 §6).
|
|
4
|
+
*
|
|
5
|
+
* In `@plantops/ui` rather than in a console because two screens need the same
|
|
6
|
+
* tree for opposite purposes and must not disagree about it: Session 31's editor
|
|
7
|
+
* *builds* the structure, and Session 35's access screen *points at* a node in
|
|
8
|
+
* it. A second copy would be two answers to "what may I select", which is the
|
|
9
|
+
* one question a grant screen has to get right.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here knows an endpoint. It takes the tree the server sent and returns
|
|
12
|
+
* shapes antd's `Tree` and `TreeSelect` accept, which is the whole of what the
|
|
13
|
+
* two screens share.
|
|
14
|
+
*
|
|
15
|
+
* ## `path` is read for exactly one thing
|
|
16
|
+
*
|
|
17
|
+
* Coverage is subtree containment: a grant at a node covers every descendant
|
|
18
|
+
* (Doc 04 §4), and `path` is how that is expressed. `descendantIds` uses it to
|
|
19
|
+
* answer "which nodes are beneath this one" without a recursive walk, because a
|
|
20
|
+
* move picker has to grey out the moving node's own subtree — moving a node
|
|
21
|
+
* under its own descendant would orphan the subtree from its tree, which the
|
|
22
|
+
* server refuses with a 409 the operator should never have been able to reach.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here parses the labels. They are `n_` + a node's UUID hex and never a
|
|
25
|
+
* display name (Doc 01 §3.5); a consumer that read meaning out of them would be
|
|
26
|
+
* doing the resolver's job badly.
|
|
27
|
+
*/
|
|
28
|
+
import { type ScopeNodeDTO, type ScopeNodeKind } from '@plantops/contracts';
|
|
29
|
+
/**
|
|
30
|
+
* Display names for the kinds the IAM suggests.
|
|
31
|
+
*
|
|
32
|
+
* A lookup with a fallback rather than a total map, because `kind` is
|
|
33
|
+
* tenant-supplied text since ADR 0002 §6 and no fixed domain exists any more.
|
|
34
|
+
* Use {@link scopeKindLabel}; reading this record directly will hand you
|
|
35
|
+
* `undefined` for a tenant's own vocabulary.
|
|
36
|
+
*/
|
|
37
|
+
export declare const SCOPE_KIND_LABEL: Readonly<Record<string, string>>;
|
|
38
|
+
/**
|
|
39
|
+
* What to call a kind in a console.
|
|
40
|
+
*
|
|
41
|
+
* An unrecognised label is a tenant's own word, so it is shown rather than
|
|
42
|
+
* hidden or replaced — capitalised for the same reason the four suggested ones
|
|
43
|
+
* are, and otherwise left exactly as the tenant typed it. Rendering something
|
|
44
|
+
* generic here would erode the legibility that justifies `kind` existing
|
|
45
|
+
* (ADR 0002 §1.2).
|
|
46
|
+
*/
|
|
47
|
+
export declare function scopeKindLabel(kind: ScopeNodeKind): string;
|
|
48
|
+
/**
|
|
49
|
+
* The order kinds normally nest in — Group → Plant → Department → Gate for a
|
|
50
|
+
* tenant that has expressed no preference.
|
|
51
|
+
*
|
|
52
|
+
* A default for the add-child form, not a rule. Doc 01 §3.5 does not pin a kind
|
|
53
|
+
* to a depth, and an organisation with a department directly under a group is
|
|
54
|
+
* modelling itself honestly rather than incorrectly; the API accepts it. So this
|
|
55
|
+
* pre-selects the likely answer and lets the operator disagree — the same
|
|
56
|
+
* arrangement `defaultKindUnder` makes for nav nodes.
|
|
57
|
+
*/
|
|
58
|
+
export declare const SCOPE_KIND_ORDER: readonly ScopeNodeKind[];
|
|
59
|
+
/**
|
|
60
|
+
* The kind a new child of `parent` most likely is.
|
|
61
|
+
*
|
|
62
|
+
* `vocabulary` is the tenant's own ordered list — `client.config`'s
|
|
63
|
+
* `scope_node_kinds` — falling back to the suggested four. A parent whose kind
|
|
64
|
+
* is not in the vocabulary at all (a tenant mid-rename, or a node created
|
|
65
|
+
* before the list changed) yields the deepest label rather than the shallowest:
|
|
66
|
+
* new nodes are far more often leaves than roots.
|
|
67
|
+
*/
|
|
68
|
+
export declare function defaultChildKind(parent: ScopeNodeDTO | null, vocabulary?: readonly ScopeNodeKind[]): ScopeNodeKind;
|
|
69
|
+
/** One node with its depth in the rendered tree and the trail above it. */
|
|
70
|
+
export interface FlatScopeNode {
|
|
71
|
+
node: ScopeNodeDTO;
|
|
72
|
+
/** 0 for a root. Counted in the tree, not read from `depth`. */
|
|
73
|
+
level: number;
|
|
74
|
+
/** Names of the ancestors, outermost first — a "Acme / Plant B" trail. */
|
|
75
|
+
ancestorNames: string[];
|
|
76
|
+
}
|
|
77
|
+
/** Every node, depth-first in display order. */
|
|
78
|
+
export declare function flattenScopeTree(tree: readonly ScopeNodeDTO[]): FlatScopeNode[];
|
|
79
|
+
/** The node with this id, anywhere in the tree. */
|
|
80
|
+
export declare function findScopeNode(tree: readonly ScopeNodeDTO[], id: string): ScopeNodeDTO | null;
|
|
81
|
+
/**
|
|
82
|
+
* The ids of `node` and everything beneath it.
|
|
83
|
+
*
|
|
84
|
+
* Includes the node itself, because every caller so far wants "the subtree I
|
|
85
|
+
* must not touch" rather than "the subtree minus its own root" — a move picker
|
|
86
|
+
* has to exclude the node as well as its descendants, since a node cannot be its
|
|
87
|
+
* own parent either.
|
|
88
|
+
*
|
|
89
|
+
* Matched on `path` rather than walked, so it is correct for a node handed in
|
|
90
|
+
* without its `children` populated.
|
|
91
|
+
*/
|
|
92
|
+
export declare function descendantIds(tree: readonly ScopeNodeDTO[], node: ScopeNodeDTO): Set<string>;
|
|
93
|
+
/** One antd tree node — the shape `Tree` and `TreeSelect` both consume. */
|
|
94
|
+
export interface ScopeTreeDataNode {
|
|
95
|
+
key: string;
|
|
96
|
+
value: string;
|
|
97
|
+
title: string;
|
|
98
|
+
/** The node this row stands for, so a renderer need not look it up again. */
|
|
99
|
+
node: ScopeNodeDTO;
|
|
100
|
+
disabled: boolean;
|
|
101
|
+
/** True when the row is only there to be expanded through. */
|
|
102
|
+
selectable: boolean;
|
|
103
|
+
children: ScopeTreeDataNode[];
|
|
104
|
+
}
|
|
105
|
+
export interface ScopeTreeDataOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Rows the operator may not choose — greyed out but still expandable, because
|
|
108
|
+
* the node they *can* choose may be underneath one they cannot.
|
|
109
|
+
*/
|
|
110
|
+
isDisabled?: (node: ScopeNodeDTO) => boolean;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The tree, as antd's `treeData`.
|
|
114
|
+
*
|
|
115
|
+
* `key` and `value` are both the node id: `Tree` reads the first and
|
|
116
|
+
* `TreeSelect` the second, and giving them the same value is what lets one
|
|
117
|
+
* function feed both.
|
|
118
|
+
*/
|
|
119
|
+
export declare function scopeTreeData(tree: readonly ScopeNodeDTO[], options?: ScopeTreeDataOptions): ScopeTreeDataNode[];
|
|
120
|
+
/** Every node id in the tree — what an "expand all" needs. */
|
|
121
|
+
export declare function allScopeNodeIds(tree: readonly ScopeNodeDTO[]): string[];
|
|
122
|
+
/** How many nodes the tree holds, and how deep it goes. */
|
|
123
|
+
export declare function scopeTreeSize(tree: readonly ScopeNodeDTO[]): {
|
|
124
|
+
nodes: number;
|
|
125
|
+
depth: number;
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=scope-tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope-tree.d.ts","sourceRoot":"","sources":["../../src/data/scope-tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,aAAa,EACnB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAK7D,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAM1D;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,EAAE,SAAS,aAAa,EAA+B,CAAC;AAErF;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,YAAY,GAAG,IAAI,EAC3B,UAAU,GAAE,SAAS,aAAa,EAAqB,GACtD,aAAa,CAOf;AAED,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,YAAY,CAAC;IACnB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,gDAAgD;AAChD,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,SAAS,YAAY,EAAE,GAC5B,aAAa,EAAE,CAkBjB;AAED,mDAAmD;AACnD,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,YAAY,EAAE,EAC7B,EAAE,EAAE,MAAM,GACT,YAAY,GAAG,IAAI,CAErB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,YAAY,EAAE,EAC7B,IAAI,EAAE,YAAY,GACjB,GAAG,CAAC,MAAM,CAAC,CAOb;AAED,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,IAAI,EAAE,YAAY,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC;CAC9C;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,YAAY,EAAE,EAC7B,OAAO,GAAE,oBAAyB,GACjC,iBAAiB,EAAE,CAiBrB;AAED,8DAA8D;AAC9D,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,EAAE,CAEvE;AAED,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAMA"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions over the `ScopeNodeDTO` tree `GET /iam/scopes` returns — the
|
|
3
|
+
* WHERE dimension of every grant (Doc 01 §3.5, Doc 06 §6).
|
|
4
|
+
*
|
|
5
|
+
* In `@plantops/ui` rather than in a console because two screens need the same
|
|
6
|
+
* tree for opposite purposes and must not disagree about it: Session 31's editor
|
|
7
|
+
* *builds* the structure, and Session 35's access screen *points at* a node in
|
|
8
|
+
* it. A second copy would be two answers to "what may I select", which is the
|
|
9
|
+
* one question a grant screen has to get right.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here knows an endpoint. It takes the tree the server sent and returns
|
|
12
|
+
* shapes antd's `Tree` and `TreeSelect` accept, which is the whole of what the
|
|
13
|
+
* two screens share.
|
|
14
|
+
*
|
|
15
|
+
* ## `path` is read for exactly one thing
|
|
16
|
+
*
|
|
17
|
+
* Coverage is subtree containment: a grant at a node covers every descendant
|
|
18
|
+
* (Doc 04 §4), and `path` is how that is expressed. `descendantIds` uses it to
|
|
19
|
+
* answer "which nodes are beneath this one" without a recursive walk, because a
|
|
20
|
+
* move picker has to grey out the moving node's own subtree — moving a node
|
|
21
|
+
* under its own descendant would orphan the subtree from its tree, which the
|
|
22
|
+
* server refuses with a 409 the operator should never have been able to reach.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here parses the labels. They are `n_` + a node's UUID hex and never a
|
|
25
|
+
* display name (Doc 01 §3.5); a consumer that read meaning out of them would be
|
|
26
|
+
* doing the resolver's job badly.
|
|
27
|
+
*/
|
|
28
|
+
import { SUGGESTED_SCOPE_NODE_KINDS, } from '@plantops/contracts';
|
|
29
|
+
/**
|
|
30
|
+
* Display names for the kinds the IAM suggests.
|
|
31
|
+
*
|
|
32
|
+
* A lookup with a fallback rather than a total map, because `kind` is
|
|
33
|
+
* tenant-supplied text since ADR 0002 §6 and no fixed domain exists any more.
|
|
34
|
+
* Use {@link scopeKindLabel}; reading this record directly will hand you
|
|
35
|
+
* `undefined` for a tenant's own vocabulary.
|
|
36
|
+
*/
|
|
37
|
+
export const SCOPE_KIND_LABEL = {
|
|
38
|
+
group: 'Group',
|
|
39
|
+
plant: 'Plant',
|
|
40
|
+
department: 'Department',
|
|
41
|
+
gate: 'Gate',
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* What to call a kind in a console.
|
|
45
|
+
*
|
|
46
|
+
* An unrecognised label is a tenant's own word, so it is shown rather than
|
|
47
|
+
* hidden or replaced — capitalised for the same reason the four suggested ones
|
|
48
|
+
* are, and otherwise left exactly as the tenant typed it. Rendering something
|
|
49
|
+
* generic here would erode the legibility that justifies `kind` existing
|
|
50
|
+
* (ADR 0002 §1.2).
|
|
51
|
+
*/
|
|
52
|
+
export function scopeKindLabel(kind) {
|
|
53
|
+
const known = SCOPE_KIND_LABEL[kind];
|
|
54
|
+
if (known !== undefined)
|
|
55
|
+
return known;
|
|
56
|
+
const trimmed = kind.trim();
|
|
57
|
+
if (trimmed.length === 0)
|
|
58
|
+
return kind;
|
|
59
|
+
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The order kinds normally nest in — Group → Plant → Department → Gate for a
|
|
63
|
+
* tenant that has expressed no preference.
|
|
64
|
+
*
|
|
65
|
+
* A default for the add-child form, not a rule. Doc 01 §3.5 does not pin a kind
|
|
66
|
+
* to a depth, and an organisation with a department directly under a group is
|
|
67
|
+
* modelling itself honestly rather than incorrectly; the API accepts it. So this
|
|
68
|
+
* pre-selects the likely answer and lets the operator disagree — the same
|
|
69
|
+
* arrangement `defaultKindUnder` makes for nav nodes.
|
|
70
|
+
*/
|
|
71
|
+
export const SCOPE_KIND_ORDER = SUGGESTED_SCOPE_NODE_KINDS;
|
|
72
|
+
/**
|
|
73
|
+
* The kind a new child of `parent` most likely is.
|
|
74
|
+
*
|
|
75
|
+
* `vocabulary` is the tenant's own ordered list — `client.config`'s
|
|
76
|
+
* `scope_node_kinds` — falling back to the suggested four. A parent whose kind
|
|
77
|
+
* is not in the vocabulary at all (a tenant mid-rename, or a node created
|
|
78
|
+
* before the list changed) yields the deepest label rather than the shallowest:
|
|
79
|
+
* new nodes are far more often leaves than roots.
|
|
80
|
+
*/
|
|
81
|
+
export function defaultChildKind(parent, vocabulary = SCOPE_KIND_ORDER) {
|
|
82
|
+
const order = vocabulary.length > 0 ? vocabulary : SCOPE_KIND_ORDER;
|
|
83
|
+
const deepest = order[order.length - 1] ?? 'gate';
|
|
84
|
+
if (parent === null)
|
|
85
|
+
return order[0] ?? 'group';
|
|
86
|
+
const index = order.indexOf(parent.kind);
|
|
87
|
+
if (index === -1)
|
|
88
|
+
return deepest;
|
|
89
|
+
return order[index + 1] ?? deepest;
|
|
90
|
+
}
|
|
91
|
+
/** Every node, depth-first in display order. */
|
|
92
|
+
export function flattenScopeTree(tree) {
|
|
93
|
+
const rows = [];
|
|
94
|
+
const walk = (nodes, level, ancestorNames) => {
|
|
95
|
+
for (const node of nodes) {
|
|
96
|
+
rows.push({ node, level, ancestorNames });
|
|
97
|
+
if (node.children.length > 0) {
|
|
98
|
+
walk(node.children, level + 1, [...ancestorNames, node.name]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(tree, 0, []);
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
/** The node with this id, anywhere in the tree. */
|
|
106
|
+
export function findScopeNode(tree, id) {
|
|
107
|
+
return flattenScopeTree(tree).find((row) => row.node.id === id)?.node ?? null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The ids of `node` and everything beneath it.
|
|
111
|
+
*
|
|
112
|
+
* Includes the node itself, because every caller so far wants "the subtree I
|
|
113
|
+
* must not touch" rather than "the subtree minus its own root" — a move picker
|
|
114
|
+
* has to exclude the node as well as its descendants, since a node cannot be its
|
|
115
|
+
* own parent either.
|
|
116
|
+
*
|
|
117
|
+
* Matched on `path` rather than walked, so it is correct for a node handed in
|
|
118
|
+
* without its `children` populated.
|
|
119
|
+
*/
|
|
120
|
+
export function descendantIds(tree, node) {
|
|
121
|
+
const prefix = `${node.path}.`;
|
|
122
|
+
const ids = new Set([node.id]);
|
|
123
|
+
for (const row of flattenScopeTree(tree)) {
|
|
124
|
+
if (row.node.path.startsWith(prefix))
|
|
125
|
+
ids.add(row.node.id);
|
|
126
|
+
}
|
|
127
|
+
return ids;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The tree, as antd's `treeData`.
|
|
131
|
+
*
|
|
132
|
+
* `key` and `value` are both the node id: `Tree` reads the first and
|
|
133
|
+
* `TreeSelect` the second, and giving them the same value is what lets one
|
|
134
|
+
* function feed both.
|
|
135
|
+
*/
|
|
136
|
+
export function scopeTreeData(tree, options = {}) {
|
|
137
|
+
const { isDisabled } = options;
|
|
138
|
+
const build = (node) => {
|
|
139
|
+
const disabled = isDisabled?.(node) ?? false;
|
|
140
|
+
return {
|
|
141
|
+
key: node.id,
|
|
142
|
+
value: node.id,
|
|
143
|
+
title: node.name,
|
|
144
|
+
node,
|
|
145
|
+
disabled,
|
|
146
|
+
selectable: !disabled,
|
|
147
|
+
children: node.children.map(build),
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
return tree.map(build);
|
|
151
|
+
}
|
|
152
|
+
/** Every node id in the tree — what an "expand all" needs. */
|
|
153
|
+
export function allScopeNodeIds(tree) {
|
|
154
|
+
return flattenScopeTree(tree).map((row) => row.node.id);
|
|
155
|
+
}
|
|
156
|
+
/** How many nodes the tree holds, and how deep it goes. */
|
|
157
|
+
export function scopeTreeSize(tree) {
|
|
158
|
+
const rows = flattenScopeTree(tree);
|
|
159
|
+
return {
|
|
160
|
+
nodes: rows.length,
|
|
161
|
+
depth: rows.reduce((deepest, row) => Math.max(deepest, row.level + 1), 0),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
/** What a state means to the person reading the row. */
|
|
3
|
+
export type StatusTone = 'good' | 'attention' | 'stopped' | 'neutral';
|
|
4
|
+
export interface StatusTagProps {
|
|
5
|
+
/** The raw enum value from the API. */
|
|
6
|
+
status: string;
|
|
7
|
+
/** Overrides the derived tone — for a state whose meaning is screen-specific. */
|
|
8
|
+
tone?: StatusTone;
|
|
9
|
+
/** Overrides the label. Defaults to the status, capitalised. */
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function statusTone(status: string): StatusTone;
|
|
13
|
+
export declare function StatusTag({ status, tone, label }: StatusTagProps): React.ReactElement;
|
|
14
|
+
//# sourceMappingURL=status-tag.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-tag.d.ts","sourceRoot":"","sources":["../../src/data/status-tag.tsx"],"names":[],"mappings":"AAmBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,wDAAwD;AACxD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,SAAS,CAAC;AA4BtE,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAErD;AAED,wBAAgB,SAAS,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,cAAc,GAAG,KAAK,CAAC,YAAY,CAQrF"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* One vocabulary for "what state is this row in".
|
|
5
|
+
*
|
|
6
|
+
* The IAM has four independent status enums — user (`active`/`locked`/
|
|
7
|
+
* `disabled`), client (`active`/`suspended`), service account (`active`/
|
|
8
|
+
* `revoked`) and the derived expired-binding flag — and they mean roughly the
|
|
9
|
+
* same three things. Rendering each with its own ad-hoc colour is how a console
|
|
10
|
+
* ends up showing `disabled` in red on one screen and grey on the next, which
|
|
11
|
+
* quietly teaches an admin that the colour means nothing.
|
|
12
|
+
*
|
|
13
|
+
* So: statuses map to a small set of *tones*, and the tone decides the colour.
|
|
14
|
+
* Unknown values render neutrally with their own label rather than throwing,
|
|
15
|
+
* because a status enum can gain a member in a migration long before this file
|
|
16
|
+
* hears about it.
|
|
17
|
+
*/
|
|
18
|
+
import { Tag } from 'antd';
|
|
19
|
+
const TONE_COLOR = {
|
|
20
|
+
good: 'green',
|
|
21
|
+
attention: 'gold',
|
|
22
|
+
stopped: 'red',
|
|
23
|
+
neutral: 'default',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Status string → tone.
|
|
27
|
+
*
|
|
28
|
+
* `locked` is `attention` rather than `stopped` on purpose: it is reversible by
|
|
29
|
+
* an administrator in one click, where `disabled` and `revoked` are decisions.
|
|
30
|
+
*/
|
|
31
|
+
const TONE_FOR_STATUS = {
|
|
32
|
+
active: 'good',
|
|
33
|
+
enabled: 'good',
|
|
34
|
+
locked: 'attention',
|
|
35
|
+
pending: 'attention',
|
|
36
|
+
expiring: 'attention',
|
|
37
|
+
disabled: 'stopped',
|
|
38
|
+
suspended: 'stopped',
|
|
39
|
+
revoked: 'stopped',
|
|
40
|
+
expired: 'stopped',
|
|
41
|
+
inactive: 'neutral',
|
|
42
|
+
};
|
|
43
|
+
export function statusTone(status) {
|
|
44
|
+
return TONE_FOR_STATUS[status.toLowerCase()] ?? 'neutral';
|
|
45
|
+
}
|
|
46
|
+
export function StatusTag({ status, tone, label }) {
|
|
47
|
+
const resolved = tone ?? statusTone(status);
|
|
48
|
+
const text = label ?? status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ');
|
|
49
|
+
return (_jsx(Tag, { color: TONE_COLOR[resolved], style: { marginInlineEnd: 0 }, children: text }));
|
|
50
|
+
}
|