@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.
Files changed (63) hide show
  1. package/README.md +74 -0
  2. package/dist/data/data-table.d.ts +41 -0
  3. package/dist/data/data-table.d.ts.map +1 -0
  4. package/dist/data/data-table.js +31 -0
  5. package/dist/data/scope-tree-select.d.ts +51 -0
  6. package/dist/data/scope-tree-select.d.ts.map +1 -0
  7. package/dist/data/scope-tree-select.js +59 -0
  8. package/dist/data/scope-tree.d.ts +127 -0
  9. package/dist/data/scope-tree.d.ts.map +1 -0
  10. package/dist/data/scope-tree.js +163 -0
  11. package/dist/data/status-tag.d.ts +14 -0
  12. package/dist/data/status-tag.d.ts.map +1 -0
  13. package/dist/data/status-tag.js +50 -0
  14. package/dist/feedback/error-copy.d.ts +38 -0
  15. package/dist/feedback/error-copy.d.ts.map +1 -0
  16. package/dist/feedback/error-copy.js +97 -0
  17. package/dist/feedback/page-header.d.ts +17 -0
  18. package/dist/feedback/page-header.d.ts.map +1 -0
  19. package/dist/feedback/page-header.js +23 -0
  20. package/dist/feedback/state-panels.d.ts +50 -0
  21. package/dist/feedback/state-panels.d.ts.map +1 -0
  22. package/dist/feedback/state-panels.js +46 -0
  23. package/dist/forms/auth-layout.d.ts +12 -0
  24. package/dist/forms/auth-layout.d.ts.map +1 -0
  25. package/dist/forms/auth-layout.js +28 -0
  26. package/dist/forms/credentials-form.d.ts +34 -0
  27. package/dist/forms/credentials-form.d.ts.map +1 -0
  28. package/dist/forms/credentials-form.js +37 -0
  29. package/dist/icons/icon-registry.d.ts +25 -0
  30. package/dist/icons/icon-registry.d.ts.map +1 -0
  31. package/dist/icons/icon-registry.js +102 -0
  32. package/dist/index.d.ts +52 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +51 -0
  35. package/dist/layout/app-shell.d.ts +23 -0
  36. package/dist/layout/app-shell.d.ts.map +1 -0
  37. package/dist/layout/app-shell.js +84 -0
  38. package/dist/layout/brand.d.ts +10 -0
  39. package/dist/layout/brand.d.ts.map +1 -0
  40. package/dist/layout/brand.js +48 -0
  41. package/dist/layout/nav-menu.d.ts +30 -0
  42. package/dist/layout/nav-menu.d.ts.map +1 -0
  43. package/dist/layout/nav-menu.js +75 -0
  44. package/dist/layout/nav-tree.d.ts +67 -0
  45. package/dist/layout/nav-tree.d.ts.map +1 -0
  46. package/dist/layout/nav-tree.js +101 -0
  47. package/dist/layout/user-menu.d.ts +20 -0
  48. package/dist/layout/user-menu.d.ts.map +1 -0
  49. package/dist/layout/user-menu.js +75 -0
  50. package/dist/theme/color-mode.d.ts +45 -0
  51. package/dist/theme/color-mode.d.ts.map +1 -0
  52. package/dist/theme/color-mode.js +81 -0
  53. package/dist/theme/theme-provider.d.ts +17 -0
  54. package/dist/theme/theme-provider.d.ts.map +1 -0
  55. package/dist/theme/theme-provider.js +65 -0
  56. package/dist/theme/theme.d.ts +23 -0
  57. package/dist/theme/theme.d.ts.map +1 -0
  58. package/dist/theme/theme.js +141 -0
  59. package/dist/theme/tokens.d.ts +130 -0
  60. package/dist/theme/tokens.d.ts.map +1 -0
  61. package/dist/theme/tokens.js +127 -0
  62. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  63. 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
+ }