@cratis/scene.blueprint.default 4.36.0 → 4.37.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 (105) hide show
  1. package/ComponentName.ts +9 -0
  2. package/defaultBlueprintComponents.ts +4 -1
  3. package/dist/cjs/ComponentName.js +3 -0
  4. package/dist/cjs/ComponentName.js.map +1 -1
  5. package/dist/cjs/defaultBlueprint.js +1 -0
  6. package/dist/cjs/defaultBlueprint.js.map +1 -1
  7. package/dist/cjs/defaultBlueprintComponentMetadata.js +1 -0
  8. package/dist/cjs/defaultBlueprintComponentMetadata.js.map +1 -1
  9. package/dist/cjs/defaultBlueprintComponents.js +7 -1
  10. package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
  11. package/dist/cjs/foundation/ApprovalChainStepper.js +130 -0
  12. package/dist/cjs/foundation/ApprovalChainStepper.js.map +1 -0
  13. package/dist/cjs/foundation/OrgTree.js +188 -0
  14. package/dist/cjs/foundation/OrgTree.js.map +1 -0
  15. package/dist/cjs/foundation/PermissionMatrix.js +136 -0
  16. package/dist/cjs/foundation/PermissionMatrix.js.map +1 -0
  17. package/dist/cjs/foundation/approvalModel.js +87 -0
  18. package/dist/cjs/foundation/approvalModel.js.map +1 -0
  19. package/dist/cjs/foundation/foundationComponentMetadata.js +21 -0
  20. package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
  21. package/dist/cjs/foundation/foundationDescriptors.js +118 -0
  22. package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
  23. package/dist/cjs/foundation/foundationSamples.js +116 -0
  24. package/dist/cjs/foundation/foundationSamples.js.map +1 -1
  25. package/dist/cjs/foundation/foundationStrings.js +12 -1
  26. package/dist/cjs/foundation/foundationStrings.js.map +1 -1
  27. package/dist/cjs/foundation/orgModel.js +106 -0
  28. package/dist/cjs/foundation/orgModel.js.map +1 -0
  29. package/dist/cjs/foundation/permissionModel.js +84 -0
  30. package/dist/cjs/foundation/permissionModel.js.map +1 -0
  31. package/dist/cjs/index.js +26 -0
  32. package/dist/cjs/index.js.map +1 -1
  33. package/dist/esm/ComponentName.d.ts +4 -1
  34. package/dist/esm/ComponentName.d.ts.map +1 -1
  35. package/dist/esm/ComponentName.js +3 -0
  36. package/dist/esm/ComponentName.js.map +1 -1
  37. package/dist/esm/defaultBlueprint.js +1 -0
  38. package/dist/esm/defaultBlueprint.js.map +1 -1
  39. package/dist/esm/defaultBlueprintComponentMetadata.js +1 -0
  40. package/dist/esm/defaultBlueprintComponentMetadata.js.map +1 -1
  41. package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
  42. package/dist/esm/defaultBlueprintComponents.js +7 -1
  43. package/dist/esm/defaultBlueprintComponents.js.map +1 -1
  44. package/dist/esm/foundation/ApprovalChainStepper.d.ts +3 -0
  45. package/dist/esm/foundation/ApprovalChainStepper.d.ts.map +1 -0
  46. package/dist/esm/foundation/ApprovalChainStepper.js +128 -0
  47. package/dist/esm/foundation/ApprovalChainStepper.js.map +1 -0
  48. package/dist/esm/foundation/Foundation.stories.d.ts +8 -0
  49. package/dist/esm/foundation/Foundation.stories.d.ts.map +1 -1
  50. package/dist/esm/foundation/Foundation.stories.js +8 -0
  51. package/dist/esm/foundation/Foundation.stories.js.map +1 -1
  52. package/dist/esm/foundation/OrgTree.d.ts +3 -0
  53. package/dist/esm/foundation/OrgTree.d.ts.map +1 -0
  54. package/dist/esm/foundation/OrgTree.js +186 -0
  55. package/dist/esm/foundation/OrgTree.js.map +1 -0
  56. package/dist/esm/foundation/PermissionMatrix.d.ts +3 -0
  57. package/dist/esm/foundation/PermissionMatrix.d.ts.map +1 -0
  58. package/dist/esm/foundation/PermissionMatrix.js +134 -0
  59. package/dist/esm/foundation/PermissionMatrix.js.map +1 -0
  60. package/dist/esm/foundation/approvalModel.d.ts +27 -0
  61. package/dist/esm/foundation/approvalModel.d.ts.map +1 -0
  62. package/dist/esm/foundation/approvalModel.js +81 -0
  63. package/dist/esm/foundation/approvalModel.js.map +1 -0
  64. package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
  65. package/dist/esm/foundation/foundationComponentMetadata.js +21 -0
  66. package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -1
  67. package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -1
  68. package/dist/esm/foundation/foundationDescriptors.js +118 -0
  69. package/dist/esm/foundation/foundationDescriptors.js.map +1 -1
  70. package/dist/esm/foundation/foundationSamples.d.ts.map +1 -1
  71. package/dist/esm/foundation/foundationSamples.js +116 -0
  72. package/dist/esm/foundation/foundationSamples.js.map +1 -1
  73. package/dist/esm/foundation/foundationStrings.d.ts +11 -0
  74. package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
  75. package/dist/esm/foundation/foundationStrings.js +12 -1
  76. package/dist/esm/foundation/foundationStrings.js.map +1 -1
  77. package/dist/esm/foundation/index.d.ts +6 -0
  78. package/dist/esm/foundation/index.d.ts.map +1 -1
  79. package/dist/esm/foundation/index.js +6 -0
  80. package/dist/esm/foundation/index.js.map +1 -1
  81. package/dist/esm/foundation/orgModel.d.ts +20 -0
  82. package/dist/esm/foundation/orgModel.d.ts.map +1 -0
  83. package/dist/esm/foundation/orgModel.js +99 -0
  84. package/dist/esm/foundation/orgModel.js.map +1 -0
  85. package/dist/esm/foundation/permissionModel.d.ts +24 -0
  86. package/dist/esm/foundation/permissionModel.d.ts.map +1 -0
  87. package/dist/esm/foundation/permissionModel.js +77 -0
  88. package/dist/esm/foundation/permissionModel.js.map +1 -0
  89. package/dist/esm/index.js +6 -0
  90. package/dist/esm/index.js.map +1 -1
  91. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  92. package/foundation/ApprovalChainStepper.tsx +66 -0
  93. package/foundation/Foundation.stories.tsx +8 -0
  94. package/foundation/OrgTree.tsx +101 -0
  95. package/foundation/PermissionMatrix.tsx +82 -0
  96. package/foundation/approvalModel.ts +88 -0
  97. package/foundation/foundationComponentMetadata.ts +3 -0
  98. package/foundation/foundationDescriptors.ts +74 -0
  99. package/foundation/foundationSamples.ts +30 -0
  100. package/foundation/foundationStrings.ts +11 -0
  101. package/foundation/index.ts +6 -0
  102. package/foundation/orgModel.ts +85 -0
  103. package/foundation/permissionModel.ts +85 -0
  104. package/package.json +8 -8
  105. package/shell/layout.css +184 -0
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useMemo } from 'react';
5
+ import { RegisteredComponentProps } from '@cratis/scene.react';
6
+ import { ApprovalState, ChainOutcome, chainOutcome, effectiveSteps, readSteps } from './approvalModel';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FoundationIcon, FoundationIconName } from './FoundationIcon';
9
+ import { formatDate, readLocale } from './formatting';
10
+ import { formatText, readFoundationStrings } from './foundationStrings';
11
+ import { FoundationFrame } from './StateFrame';
12
+
13
+ const icons: Record<ApprovalState, FoundationIconName> = {
14
+ [ApprovalState.Pending]: 'circle', [ApprovalState.Current]: 'clock', [ApprovalState.Approved]: 'check', [ApprovalState.Rejected]: 'close', [ApprovalState.Skipped]: 'minus',
15
+ };
16
+ const tones: Record<ApprovalState, string> = {
17
+ [ApprovalState.Pending]: 'neutral', [ApprovalState.Current]: 'info', [ApprovalState.Approved]: 'success', [ApprovalState.Rejected]: 'danger', [ApprovalState.Skipped]: 'neutral',
18
+ };
19
+
20
+ /**
21
+ * The `approvalChainStepper` component: the steps a request goes through, who decides each, and where it stands.
22
+ *
23
+ * `steps` are written in order with a `label`, an `approver`, a `state` (pending, current, approved, rejected, skipped), and when it was
24
+ * decided (`at`) and a `comment`. The chain applies its own rules - a rejection stops the chain and the steps after it read "Not reached", and
25
+ * with no current step the first undecided one is it - so a request page needs no logic of its own. Every step says its state in words as
26
+ * well as by icon and color, the current step carries `aria-current="step"`, and the whole is an ordered list with the overall outcome
27
+ * stated above it. `orientation` is `horizontal` or `vertical`; below a phone's width it is always vertical.
28
+ */
29
+ export function ApprovalChainStepper({ element, interactions }: RegisteredComponentProps) {
30
+ const written = useMemo(() => readSteps(element.properties.steps), [element.properties.steps]);
31
+ const steps = useMemo(() => effectiveSteps(written), [written]);
32
+ const outcome = chainOutcome(written);
33
+ const strings = readFoundationStrings(element);
34
+ const locale = readLocale(element);
35
+ const state = readComponentState(element, written.length > 0);
36
+ const stateText: Record<ApprovalState, string> = {
37
+ pending: strings.approvalPending, current: strings.approvalCurrent, approved: strings.approvalApproved, rejected: strings.approvalRejected, skipped: strings.approvalSkipped,
38
+ };
39
+ const outcomeText: Record<ChainOutcome, string> = {
40
+ approved: strings.approvalOutcomeApproved, rejected: strings.approvalOutcomeRejected, inProgress: strings.approvalOutcomeInProgress, pending: strings.approvalOutcomePending,
41
+ };
42
+
43
+ return (
44
+ <FoundationFrame element={element} interactions={interactions} name='approval-chain' state={state} skeleton={[100, 100]}>
45
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
46
+ <>
47
+ <p className='foundation-approval-outcome' data-outcome={outcome}>{outcomeText[outcome]}</p>
48
+ <ol className='foundation-approval-steps' aria-label={strings.approvalChain} data-orientation={element.properties.orientation === 'vertical' ? 'vertical' : 'horizontal'}>
49
+ {steps.map((step, index) => (
50
+ <li key={step.label + index} className='foundation-approval-step' data-state={step.state} aria-current={step.state === ApprovalState.Current ? 'step' : undefined}>
51
+ <span className='foundation-chip' data-tone={tones[step.state]}><FoundationIcon name={icons[step.state]} /><span>{step.notReached ? strings.approvalNotReached : stateText[step.state]}</span></span>
52
+ <strong className='foundation-approval-label'>
53
+ <span className='foundation-visually-hidden'>{`${formatText(strings.approvalStep, { n: index + 1, total: steps.length })}: `}</span>
54
+ {step.label}
55
+ </strong>
56
+ {step.approver && <span className='foundation-count'>{step.approver}</span>}
57
+ {step.at && <time className='foundation-count' dateTime={step.at}>{formatDate(step.at, locale, true)}</time>}
58
+ {step.comment && <span className='foundation-approval-comment'>{step.comment}</span>}
59
+ </li>
60
+ ))}
61
+ </ol>
62
+ </>
63
+ )}
64
+ </FoundationFrame>
65
+ );
66
+ }
@@ -52,3 +52,11 @@ export const AvatarStackLight: Story = { args: { component: ComponentName.Avatar
52
52
  export const AvatarStackDark: Story = { args: { component: ComponentName.AvatarStack, theme: 'dark' } };
53
53
  export const FileUploaderLight: Story = { args: { component: ComponentName.FileUploader, theme: 'light' } };
54
54
  export const FileUploaderDark: Story = { args: { component: ComponentName.FileUploader, theme: 'dark' } };
55
+ export const ApprovalChainLight: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'light' } };
56
+ export const ApprovalChainDark: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'dark' } };
57
+ export const OrgTreeLight: Story = { args: { component: ComponentName.OrgTree, theme: 'light' } };
58
+ export const OrgTreeDark: Story = { args: { component: ComponentName.OrgTree, theme: 'dark' } };
59
+ export const PermissionMatrixLight: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'light' } };
60
+ export const PermissionMatrixDark: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'dark' } };
61
+ export const ApprovalChainRejectedLight: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'light', properties: { steps: [{ label: 'Team lead', state: 'approved' }, { label: 'Finance', state: 'rejected', comment: 'Over budget.' }, { label: 'Director' }] } } };
62
+ export const PermissionMatrixReadOnlyDark: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'dark', properties: { editPermission: 'disabled' } } };
@@ -0,0 +1,101 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { KeyboardEvent, useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ComponentState, readComponentState } from './ComponentState';
7
+ import { FoundationIcon } from './FoundationIcon';
8
+ import { formatText, readFoundationStrings } from './foundationStrings';
9
+ import { descendantCount, expandedToDepth, OrgTreeNode, buildOrgTree, placements, readOrgNodes, visibleNodes } from './orgModel';
10
+ import { FoundationFrame } from './StateFrame';
11
+
12
+ /**
13
+ * The `orgTree` component: an organization as an expandable tree of people and units.
14
+ *
15
+ * `nodes` are flat rows - `id`, `label`, `title`, `parentId` - as a query returns them; the tree is built from them, so someone whose manager is
16
+ * missing becomes a root and a reporting cycle is cut instead of looping. It follows the ARIA tree pattern: one tab stop, Up and Down move,
17
+ * Right expands or enters, Left collapses or goes to the parent, Home and End jump, Enter or Space selects. Each item says how many reports it
18
+ * has in words. The selected id is published as the `value` output; `expandedDepth` is how many levels are open at first.
19
+ */
20
+ export function OrgTree({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
21
+ const nodes = useMemo(() => readOrgNodes(element.properties.nodes), [element.properties.nodes]);
22
+ const roots = useMemo(() => buildOrgTree(nodes), [nodes]);
23
+ const depth = typeof element.properties.expandedDepth === 'number' ? element.properties.expandedDepth : 2;
24
+ const initial = useMemo(() => expandedToDepth(roots, depth), [roots, depth]);
25
+ const [expanded, setExpanded] = useState<Set<string>>(initial);
26
+ const [selected, setSelected] = useState<string | undefined>(typeof element.properties.selected === 'string' ? element.properties.selected : undefined);
27
+ const [focused, setFocused] = useState<string | undefined>(undefined);
28
+ const strings = readFoundationStrings(element);
29
+ const state = readComponentState(element, nodes.length > 0);
30
+ const visible = visibleNodes(roots, expanded);
31
+ const placed = useMemo(() => placements(roots), [roots]);
32
+ const tabStop = visible.find(node => node.id === focused)?.id ?? visible.find(node => node.id === selected)?.id ?? visible[0]?.id;
33
+ const emptyElement = state === ComponentState.Empty ? { ...element, properties: { emptyHeadline: strings.orgNone, ...element.properties } } : element;
34
+
35
+ useEffect(() => setExpanded(initial), [initial]);
36
+ useValueOutput(bindingOutputs, selected);
37
+
38
+ const move = (node: OrgTreeNode, event: KeyboardEvent) => {
39
+ const index = visible.indexOf(node);
40
+ const focus = (target: OrgTreeNode | undefined) => {
41
+ if (!target) return;
42
+ event.preventDefault();
43
+ setFocused(target.id);
44
+ requestAnimationFrame(() => document.getElementById(`${element.id}-${target.id}`)?.focus());
45
+ };
46
+ const toggle = (open: boolean) => setExpanded(current => { const next = new Set(current); if (open) next.add(node.id); else next.delete(node.id); return next; });
47
+ const parent = placed.get(node.id)?.parent;
48
+
49
+ switch (event.key) {
50
+ case 'ArrowDown': focus(visible[index + 1]); break;
51
+ case 'ArrowUp': focus(visible[index - 1]); break;
52
+ case 'Home': focus(visible[0]); break;
53
+ case 'End': focus(visible[visible.length - 1]); break;
54
+ case 'ArrowRight':
55
+ if (node.children.length > 0 && !expanded.has(node.id)) { event.preventDefault(); toggle(true); } else focus(node.children[0]);
56
+ break;
57
+ case 'ArrowLeft':
58
+ if (node.children.length > 0 && expanded.has(node.id)) { event.preventDefault(); toggle(false); } else focus(parent);
59
+ break;
60
+ case 'Enter':
61
+ case ' ':
62
+ event.preventDefault();
63
+ setSelected(node.id);
64
+ interactions?.onSelect?.();
65
+ break;
66
+ default: break;
67
+ }
68
+ };
69
+
70
+ return (
71
+ <FoundationFrame element={emptyElement} interactions={interactions} name='org-tree' state={state} skeleton={[50, 70, 70, 60]}>
72
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
73
+ <ul role='tree' aria-label={strings.orgTree} className='foundation-tree'>
74
+ {visible.map(node => {
75
+ const siblings = placed.get(node.id)?.siblings ?? [node];
76
+ const reports = descendantCount(node);
77
+ const open = expanded.has(node.id);
78
+ return (
79
+ <li key={node.id} id={`${element.id}-${node.id}`} role='treeitem' tabIndex={node.id === tabStop ? 0 : -1} aria-level={node.depth}
80
+ aria-setsize={siblings.length} aria-posinset={siblings.findIndex(sibling => sibling.id === node.id) + 1} aria-expanded={node.children.length > 0 ? open : undefined} aria-selected={node.id === selected}
81
+ className='foundation-tree-item' data-selected={node.id === selected} style={{ paddingInlineStart: `calc(var(--foundation-space-4) * ${node.depth - 1})` }}
82
+ onKeyDown={event => move(node, event)} onFocus={() => setFocused(node.id)} onClick={() => { setSelected(node.id); interactions?.onSelect?.(); }}>
83
+ {node.children.length > 0 ? (
84
+ <button type='button' tabIndex={-1} className='foundation-icon-button' aria-hidden='true'
85
+ onClick={event => { event.stopPropagation(); setExpanded(current => { const next = new Set(current); if (open) next.delete(node.id); else next.add(node.id); return next; }); }}>
86
+ <FoundationIcon name={open ? 'chevron-down' : 'chevron-right'} />
87
+ </button>
88
+ ) : <span className='foundation-tree-spacer' />}
89
+ <span className='foundation-tree-text'>
90
+ <strong>{node.label}</strong>
91
+ {node.title && <span className='foundation-count'>{node.title}</span>}
92
+ </span>
93
+ {reports > 0 && <span className='foundation-count'>{formatText(strings.orgReports, { count: reports })}</span>}
94
+ </li>
95
+ );
96
+ })}
97
+ </ul>
98
+ )}
99
+ </FoundationFrame>
100
+ );
101
+ }
@@ -0,0 +1,82 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Fragment, useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ComponentState, readComponentState } from './ComponentState';
7
+ import { FoundationIcon } from './FoundationIcon';
8
+ import { formatText, readFoundationStrings } from './foundationStrings';
9
+ import { isGranted, MatrixGrant, readGrants, readPermissions, readRoles, sectionsOf, setGrant } from './permissionModel';
10
+ import { readPermissionRule } from './PermissionRule';
11
+ import { FoundationFrame } from './StateFrame';
12
+
13
+ /**
14
+ * The `permissionMatrix` component: roles across, permissions down, a checkbox where they meet.
15
+ *
16
+ * `roles` and `permissions` (optionally grouped) give the axes and `grants` - rows of `role` and `permission` - what is held; a grant of a role
17
+ * or permission that does not exist is ignored. Each box is named in words ("Grant Edit deals to Sales") and every header is a real header
18
+ * cell, so a screen reader announces the row and column of the box it is on. Changes are published as the `value` output - the grants,
19
+ * in the same shape - for the screen to save. With `editPermission` not enabled the matrix is read-only and each cell says "Allowed" or
20
+ * "Not allowed" in words and by icon. Wide matrices scroll sideways inside a keyboard-focusable region, keeping the permission column in view.
21
+ */
22
+ export function PermissionMatrix({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
23
+ const roles = useMemo(() => readRoles(element.properties.roles), [element.properties.roles]);
24
+ const permissions = useMemo(() => readPermissions(element.properties.permissions), [element.properties.permissions]);
25
+ const supplied = useMemo(() => readGrants(element.properties.grants, roles, permissions), [element.properties.grants, roles, permissions]);
26
+ const suppliedKey = JSON.stringify(supplied);
27
+ const [grants, setGrants] = useState<MatrixGrant[]>(supplied);
28
+ const strings = readFoundationStrings(element);
29
+ const state = readComponentState(element, roles.length > 0 && permissions.length > 0);
30
+ const readOnly = readPermissionRule(element, 'editPermission') !== 'enabled';
31
+ const sections = useMemo(() => sectionsOf(permissions), [permissions]);
32
+
33
+ useEffect(() => setGrants(supplied), [suppliedKey]);
34
+ useValueOutput(bindingOutputs, grants);
35
+
36
+ return (
37
+ <FoundationFrame element={element} interactions={interactions} name='permission-matrix' state={state} skeleton={[100, 100, 100, 100]}>
38
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
39
+ <div className='foundation-matrix-scroll' role='region' tabIndex={0} aria-label={strings.matrixScroll}>
40
+ <table className='foundation-matrix'>
41
+ <thead>
42
+ <tr>
43
+ <th scope='col'>{strings.matrixPermission}</th>
44
+ {roles.map(role => <th key={role.id} scope='col'>{role.label}</th>)}
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ {sections.map((section, index) => (
49
+ <Fragment key={section.group ?? `ungrouped-${index}`}>
50
+ {section.group && (
51
+ <tr className='foundation-matrix-group'><th scope='colgroup' colSpan={roles.length + 1}>{section.group}</th></tr>
52
+ )}
53
+ {section.permissions.map(permission => (
54
+ <tr key={permission.id}>
55
+ <th scope='row'>{permission.label}</th>
56
+ {roles.map(role => {
57
+ const granted = isGranted(grants, role.id, permission.id);
58
+ return (
59
+ <td key={role.id} data-granted={granted}>
60
+ {readOnly ? (
61
+ <span className='foundation-matrix-state'>
62
+ <FoundationIcon name={granted ? 'check' : 'minus'} />
63
+ <span className='foundation-visually-hidden'>{granted ? strings.matrixAllowed : strings.matrixDenied}</span>
64
+ </span>
65
+ ) : (
66
+ <input type='checkbox' checked={granted} aria-label={formatText(strings.matrixGrant, { permission: permission.label, role: role.label })}
67
+ onChange={event => { setGrants(setGrant(grants, role.id, permission.id, event.target.checked)); interactions?.onChange?.(); }} />
68
+ )}
69
+ </td>
70
+ );
71
+ })}
72
+ </tr>
73
+ ))}
74
+ </Fragment>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ )}
80
+ </FoundationFrame>
81
+ );
82
+ }
@@ -0,0 +1,88 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** Where one step of an approval chain stands. */
5
+ export enum ApprovalState {
6
+ Pending = 'pending',
7
+ Current = 'current',
8
+ Approved = 'approved',
9
+ Rejected = 'rejected',
10
+ Skipped = 'skipped',
11
+ }
12
+
13
+ /** One step of an approval chain: who decides, and what they decided. */
14
+ export interface ApprovalStep {
15
+ label: string;
16
+ approver?: string;
17
+ state: ApprovalState;
18
+ at?: string;
19
+ comment?: string;
20
+ }
21
+
22
+ /** A step as it is drawn, after the chain's own rules are applied. */
23
+ export interface EffectiveStep extends ApprovalStep {
24
+ /** True when an earlier rejection stopped the chain before this step was reached. */
25
+ notReached: boolean;
26
+ }
27
+
28
+ /** How the chain as a whole stands. */
29
+ export enum ChainOutcome {
30
+ /** Every step approved. */
31
+ Approved = 'approved',
32
+
33
+ /** A step rejected: the chain stops there. */
34
+ Rejected = 'rejected',
35
+
36
+ /** At least one step is decided and the rest are waiting. */
37
+ InProgress = 'inProgress',
38
+
39
+ /** Nothing decided yet. */
40
+ Pending = 'pending',
41
+ }
42
+
43
+ /** Reads steps out of a property value, dropping entries without a label; an unknown state is pending. */
44
+ export function readSteps(value: unknown): ApprovalStep[] {
45
+ if (!Array.isArray(value)) return [];
46
+
47
+ return value.flatMap((entry): ApprovalStep[] => {
48
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
49
+ const { label, approver, state, at, comment } = entry as Record<string, unknown>;
50
+ if (typeof label !== 'string' || label.length === 0) return [];
51
+
52
+ return [{
53
+ label,
54
+ state: (Object.values(ApprovalState) as string[]).includes(state as string) ? (state as ApprovalState) : ApprovalState.Pending,
55
+ ...(typeof approver === 'string' ? { approver } : {}),
56
+ ...(typeof at === 'string' ? { at } : {}),
57
+ ...(typeof comment === 'string' && comment.length > 0 ? { comment } : {}),
58
+ }];
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Applies the chain's rules to the steps as written.
64
+ *
65
+ * A rejection stops the chain: every step after the first rejected one has not been reached, so it is drawn skipped however it was written.
66
+ * With no step marked current and no rejection, the first step that is not yet decided is the current one - the person the chain is waiting
67
+ * for - so a chain written only as approved and pending steps still says whose turn it is. Only one step is ever current.
68
+ */
69
+ export function effectiveSteps(steps: ApprovalStep[]): EffectiveStep[] {
70
+ const rejected = steps.findIndex(step => step.state === ApprovalState.Rejected);
71
+ const explicitCurrent = steps.findIndex(step => step.state === ApprovalState.Current);
72
+ const waiting = rejected >= 0 ? -1 : explicitCurrent >= 0 ? explicitCurrent : steps.findIndex(step => step.state === ApprovalState.Pending);
73
+
74
+ return steps.map((step, index) => {
75
+ if (rejected >= 0 && index > rejected) return { ...step, state: ApprovalState.Skipped, notReached: true };
76
+ if (step.state === ApprovalState.Current && index !== waiting) return { ...step, state: ApprovalState.Pending, notReached: false };
77
+ if (index === waiting && step.state === ApprovalState.Pending) return { ...step, state: ApprovalState.Current, notReached: false };
78
+ return { ...step, notReached: false };
79
+ });
80
+ }
81
+
82
+ /** The state of the chain as a whole. A skipped step counts as decided, because it was never going to be asked. */
83
+ export function chainOutcome(steps: ApprovalStep[]): ChainOutcome {
84
+ const effective = effectiveSteps(steps);
85
+ if (effective.some(step => step.state === ApprovalState.Rejected)) return ChainOutcome.Rejected;
86
+ if (effective.length > 0 && effective.every(step => step.state === ApprovalState.Approved || step.state === ApprovalState.Skipped)) return ChainOutcome.Approved;
87
+ return effective.some(step => step.state === ApprovalState.Approved) ? ChainOutcome.InProgress : ChainOutcome.Pending;
88
+ }
@@ -22,4 +22,7 @@ export const foundationComponentMetadata: Record<string, ComponentMetadata> = {
22
22
  [ComponentName.ActivityFeed]: m('Activity feed', ComponentCategory.DataDisplay, 'activity', ['timeline', 'history', 'comments', 'audit', 'log']),
23
23
  [ComponentName.AvatarStack]: m('Avatar stack', ComponentCategory.DataDisplay, 'users', ['people', 'members', 'assignees', 'team']),
24
24
  [ComponentName.FileUploader]: m('File uploader', ComponentCategory.Input, 'upload', ['attachments', 'files', 'drop zone', 'documents']),
25
+ [ComponentName.ApprovalChainStepper]: m('Approval chain', ComponentCategory.Navigation, 'workflow', ['approvals', 'steps', 'workflow', 'sign off', 'stepper']),
26
+ [ComponentName.OrgTree]: m('Org tree', ComponentCategory.DataDisplay, 'network', ['organization', 'hierarchy', 'org chart', 'reports to', 'tree']),
27
+ [ComponentName.PermissionMatrix]: m('Permission matrix', ComponentCategory.Input, 'shield-check', ['roles', 'permissions', 'access', 'grants', 'rbac']),
25
28
  };
@@ -6,6 +6,7 @@ import { componentRegistryKey, valueOutputProperty } from '@cratis/scene.react';
6
6
  import { ComponentName } from '../ComponentName';
7
7
  import { defaultBlueprintName } from '../packageName';
8
8
  import { ActivityKind } from './activityModel';
9
+ import { ApprovalState } from './approvalModel';
9
10
  import { ChartKind } from './chartGeometry';
10
11
  import { ComponentState } from './ComponentState';
11
12
  import { FilterFieldType } from './filterModel';
@@ -279,5 +280,78 @@ export const foundationDescriptors: ComponentDescriptor[] = [
279
280
  ...standardStateProperties.filter(property => property.path !== 'actionPermission'),
280
281
  ],
281
282
  },
283
+ {
284
+ component: key(ComponentName.ApprovalChainStepper),
285
+ displayName: 'Approval chain',
286
+ description: 'The steps a request goes through, who decides each and where it stands. A rejection stops the chain; the first undecided step is the current one.',
287
+ properties: [
288
+ {
289
+ ...list('steps', 'Steps', 'Data', 'Step', [
290
+ text('label', 'Step', 'Content', undefined, { constraints: { required: true } }),
291
+ text('approver', 'Approver', 'Data'),
292
+ choice('state', 'State', 'Data', {
293
+ [ApprovalState.Pending]: 'Pending', [ApprovalState.Current]: 'Awaiting decision', [ApprovalState.Approved]: 'Approved', [ApprovalState.Rejected]: 'Rejected', [ApprovalState.Skipped]: 'Skipped',
294
+ }, ApprovalState.Pending),
295
+ text('at', 'Decided at', 'Data', 'An ISO 8601 date and time.'),
296
+ text('comment', 'Comment', 'Data'),
297
+ ], 'The approvers in order.'),
298
+ acceptedBindingKinds: bindable,
299
+ },
300
+ choice('orientation', 'Orientation', 'Appearance', { horizontal: 'Horizontal', vertical: 'Vertical' }, 'horizontal', 'A phone always reads vertically.'),
301
+ ...standardStateProperties,
302
+ ],
303
+ },
304
+ {
305
+ component: key(ComponentName.OrgTree),
306
+ displayName: 'Org tree',
307
+ description: 'An organization as an expandable tree built from rows of who reports to whom. Publishes the selected id as the value output.',
308
+ properties: [
309
+ {
310
+ ...list('nodes', 'People', 'Data', 'Person', [
311
+ text('id', 'Id', 'Data', undefined, { constraints: { required: true }, editorKind: PropertyEditorKind.Field }),
312
+ text('label', 'Name', 'Data', undefined, { editorKind: PropertyEditorKind.Field }),
313
+ text('title', 'Title', 'Data', undefined, { editorKind: PropertyEditorKind.Field }),
314
+ text('parentId', 'Reports to', 'Data', 'The id of the person above; empty for the top.', { editorKind: PropertyEditorKind.Field }),
315
+ ], 'One row per person or unit.'),
316
+ acceptedBindingKinds: bindable,
317
+ },
318
+ number('expandedDepth', 'Levels open at first', 'Appearance', undefined, { default: 2, constraints: { minimum: 1, integer: true } }),
319
+ text('selected', 'Selected', 'Data', 'The id of the selected node; bind it to start from one.'),
320
+ valueOutputProperty(PropertyValueType.String, 'The id of the selected node, published as the user selects.'),
321
+ ...standardStateProperties,
322
+ ],
323
+ },
324
+ {
325
+ component: key(ComponentName.PermissionMatrix),
326
+ displayName: 'Permission matrix',
327
+ description: 'Roles across, permissions down, a checkbox where they meet. Publishes the grants as the value output.',
328
+ properties: [
329
+ {
330
+ ...list('roles', 'Roles', 'Data', 'Role', [
331
+ text('id', 'Id', 'Data', undefined, { constraints: { required: true } }),
332
+ text('label', 'Name', 'Data'),
333
+ ], 'The columns.'),
334
+ acceptedBindingKinds: bindable,
335
+ },
336
+ {
337
+ ...list('permissions', 'Permissions', 'Data', 'Permission', [
338
+ text('id', 'Id', 'Data', undefined, { constraints: { required: true } }),
339
+ text('label', 'Name', 'Data'),
340
+ text('group', 'Group', 'Data', 'A heading the permission is listed under.'),
341
+ ], 'The rows.'),
342
+ acceptedBindingKinds: bindable,
343
+ },
344
+ {
345
+ ...list('grants', 'Grants', 'Data', 'Grant', [
346
+ text('role', 'Role', 'Data', 'The id of a role.', { constraints: { required: true } }),
347
+ text('permission', 'Permission', 'Data', 'The id of a permission.', { constraints: { required: true } }),
348
+ ], 'What each role holds.'),
349
+ acceptedBindingKinds: bindable,
350
+ },
351
+ rule('editPermission', 'Edit permission', 'Enabled lets the user change grants; otherwise the matrix is read-only.'),
352
+ valueOutputProperty(PropertyValueType.Json, 'The grants as the user has changed them: rows of role and permission, published as they change.'),
353
+ ...standardStateProperties,
354
+ ],
355
+ },
282
356
  ];
283
357
 
@@ -78,6 +78,36 @@ export const foundationSamples: Record<string, FoundationSample> = {
78
78
  properties: { accept: '.pdf, image/*', maxSizeMb: 10, multiple: true },
79
79
  data: [], stateful: true,
80
80
  },
81
+ [ComponentName.ApprovalChainStepper]: {
82
+ properties: {
83
+ locale: 'en-US',
84
+ steps: [
85
+ { label: 'Team lead', approver: 'Grace Hopper', state: 'approved', at: '2026-03-09T10:00:00Z', comment: 'Looks fine.' },
86
+ { label: 'Finance', approver: 'Linus Torvalds', state: 'current' },
87
+ { label: 'Director', approver: 'Ada Lovelace', state: 'pending' },
88
+ ],
89
+ },
90
+ data: ['Grace Hopper', 'Looks fine.'], stateful: true,
91
+ },
92
+ [ComponentName.OrgTree]: {
93
+ properties: {
94
+ nodes: [
95
+ { id: 'ceo', label: 'Ada Lovelace', title: 'Chief executive' },
96
+ { id: 'cto', label: 'Grace Hopper', title: 'Chief technology officer', parentId: 'ceo' },
97
+ { id: 'cfo', label: 'Linus Torvalds', title: 'Chief financial officer', parentId: 'ceo' },
98
+ { id: 'dev', label: 'Margaret Hamilton', title: 'Engineering manager', parentId: 'cto' },
99
+ ],
100
+ },
101
+ data: ['Chief technology officer'], stateful: true,
102
+ },
103
+ [ComponentName.PermissionMatrix]: {
104
+ properties: {
105
+ roles: [{ id: 'admin', label: 'Administrator' }, { id: 'sales', label: 'Sales' }],
106
+ permissions: [{ id: 'view', label: 'View deals', group: 'Deals' }, { id: 'edit', label: 'Edit deals', group: 'Deals' }, { id: 'users', label: 'Manage users', group: 'Administration' }],
107
+ grants: [{ role: 'admin', permission: 'view' }, { role: 'admin', permission: 'edit' }, { role: 'admin', permission: 'users' }, { role: 'sales', permission: 'view' }],
108
+ },
109
+ data: ['Edit deals', 'Manage users'], stateful: true,
110
+ },
81
111
  };
82
112
 
83
113
  /** A scene element for a foundation component, populated with its sample unless properties are given, with the state forced when asked. */
@@ -113,13 +113,24 @@ export const foundationStrings = {
113
113
  approvalRejected: 'Rejected',
114
114
  approvalSkipped: 'Skipped',
115
115
  approvalChain: 'Approval chain',
116
+ approvalStep: 'Step {n} of {total}',
117
+ approvalNotReached: 'Not reached',
118
+ approvalOutcomeApproved: 'Approved by everyone',
119
+ approvalOutcomeRejected: 'Rejected',
120
+ approvalOutcomeInProgress: 'Waiting for a decision',
121
+ approvalOutcomePending: 'Not started',
116
122
  orgExpand: 'Expand {name}',
117
123
  orgCollapse: 'Collapse {name}',
118
124
  orgReports: '{count} reports',
125
+ orgTree: 'Organization',
126
+ orgNone: 'There is nobody in the organization yet',
119
127
  matrixAllowed: 'Allowed',
120
128
  matrixDenied: 'Not allowed',
121
129
  matrixGrant: 'Grant {permission} to {role}',
122
130
  matrixReadOnly: 'Read-only',
131
+ matrixPermission: 'Permission',
132
+ matrixScroll: 'Permission matrix, scrolls sideways',
133
+ matrixGroupAll: 'Other',
123
134
  } as const;
124
135
 
125
136
  /** The name of one of the foundation strings. */
@@ -30,6 +30,12 @@ export * from './AvatarStack';
30
30
  export * from './uploadModel';
31
31
  export * from './FileUploaderHandler';
32
32
  export * from './FileUploader';
33
+ export * from './approvalModel';
34
+ export * from './ApprovalChainStepper';
35
+ export * from './orgModel';
36
+ export * from './OrgTree';
37
+ export * from './permissionModel';
38
+ export * from './PermissionMatrix';
33
39
  export * from './foundationDescriptors';
34
40
  export * from './foundationComponentMetadata';
35
41
  export * from './foundationSamples';
@@ -0,0 +1,85 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** A person or unit in an organization, with who they report to. */
5
+ export interface OrgNode {
6
+ id: string;
7
+ label: string;
8
+ title?: string;
9
+ parentId?: string;
10
+ }
11
+
12
+ /** A node placed in the tree. */
13
+ export interface OrgTreeNode extends OrgNode {
14
+ children: OrgTreeNode[];
15
+ depth: number;
16
+ }
17
+
18
+ /** Reads nodes out of a property value, dropping entries without an id and keeping the first of a repeated id. */
19
+ export function readOrgNodes(value: unknown): OrgNode[] {
20
+ if (!Array.isArray(value)) return [];
21
+
22
+ const seen = new Set<string>();
23
+ return value.flatMap((entry): OrgNode[] => {
24
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
25
+ const { id, label, title, parentId } = entry as Record<string, unknown>;
26
+ if (typeof id !== 'string' || id.length === 0 || seen.has(id)) return [];
27
+ seen.add(id);
28
+ return [{ id, label: typeof label === 'string' && label.length > 0 ? label : id, ...(typeof title === 'string' ? { title } : {}), ...(typeof parentId === 'string' && parentId.length > 0 ? { parentId } : {}) }];
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Builds the tree from the flat rows a query returns.
34
+ *
35
+ * Someone whose manager is not in the data becomes a root rather than vanishing, and a reporting cycle is broken at the node that closes it
36
+ * - which becomes a root - so malformed data draws something and never loops. Siblings keep the order of the rows.
37
+ */
38
+ export function buildOrgTree(nodes: OrgNode[]): OrgTreeNode[] {
39
+ const known = new Set(nodes.map(node => node.id));
40
+ const parentOf = new Map<string, string | undefined>(nodes.map(node => [node.id, node.parentId !== undefined && known.has(node.parentId) && node.parentId !== node.id ? node.parentId : undefined]));
41
+
42
+ for (const node of nodes) {
43
+ const path = new Set<string>([node.id]);
44
+ for (let current = parentOf.get(node.id); current !== undefined; current = parentOf.get(current)) {
45
+ if (path.has(current)) { parentOf.set(node.id, undefined); break; }
46
+ path.add(current);
47
+ }
48
+ }
49
+
50
+ const placed = new Map<string, OrgTreeNode>(nodes.map(node => [node.id, { ...node, children: [], depth: 0 }]));
51
+ const roots: OrgTreeNode[] = [];
52
+ for (const node of nodes) {
53
+ const parent = parentOf.get(node.id);
54
+ if (parent === undefined) roots.push(placed.get(node.id)!);
55
+ else placed.get(parent)!.children.push(placed.get(node.id)!);
56
+ }
57
+
58
+ const assignDepth = (level: OrgTreeNode[], depth: number) => level.forEach(node => { node.depth = depth; assignDepth(node.children, depth + 1); });
59
+ assignDepth(roots, 1);
60
+ return roots;
61
+ }
62
+
63
+ /** The nodes that can be reached from the roots with the given expansion, in the order they are drawn. */
64
+ export function visibleNodes(roots: OrgTreeNode[], expanded: ReadonlySet<string>): OrgTreeNode[] {
65
+ return roots.flatMap(node => [node, ...(expanded.has(node.id) ? visibleNodes(node.children, expanded) : [])]);
66
+ }
67
+
68
+ /** The ids of every node down to a depth: what is expanded when the tree first opens. */
69
+ export function expandedToDepth(roots: OrgTreeNode[], depth: number): Set<string> {
70
+ const result = new Set<string>();
71
+ const walk = (level: OrgTreeNode[]) => level.forEach(node => { if (node.depth < depth && node.children.length > 0) result.add(node.id); walk(node.children); });
72
+ walk(roots);
73
+ return result;
74
+ }
75
+
76
+ /** The number of people below a node, at any depth. */
77
+ export const descendantCount = (node: OrgTreeNode): number => node.children.reduce((sum, child) => sum + 1 + descendantCount(child), 0);
78
+
79
+ /** For each node, the nodes that share its parent (itself included, in order) and its parent: what a tree needs for its set size and position, and for Left to go up. */
80
+ export function placements(roots: OrgTreeNode[]): Map<string, { siblings: OrgTreeNode[]; parent?: OrgTreeNode }> {
81
+ const result = new Map<string, { siblings: OrgTreeNode[]; parent?: OrgTreeNode }>();
82
+ const walk = (level: OrgTreeNode[], parent?: OrgTreeNode) => level.forEach(node => { result.set(node.id, { siblings: level, parent }); walk(node.children, node); });
83
+ walk(roots);
84
+ return result;
85
+ }