@parallelworks/ui 0.52.0 → 0.54.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 (92) hide show
  1. package/README.md +97 -8
  2. package/dist/access/AccessDrawer.d.ts +55 -0
  3. package/dist/access/AccessDrawer.js +445 -0
  4. package/dist/access/AccessList.d.ts +58 -0
  5. package/dist/access/AccessList.js +382 -0
  6. package/dist/access/GrantView.d.ts +35 -0
  7. package/dist/access/GrantView.js +188 -0
  8. package/dist/access/GroupPicker.d.ts +35 -0
  9. package/dist/access/GroupPicker.js +336 -0
  10. package/dist/access/focus.d.ts +10 -0
  11. package/dist/access/focus.js +27 -0
  12. package/dist/access/index.d.ts +3 -0
  13. package/dist/access/index.js +3 -0
  14. package/dist/access/model.d.ts +49 -0
  15. package/dist/access/model.js +129 -0
  16. package/dist/access/strings.d.ts +122 -0
  17. package/dist/access/strings.js +151 -0
  18. package/dist/access/types.d.ts +56 -0
  19. package/dist/ai/ai.css +5 -3
  20. package/dist/ai/components/SlashMenu.js +1 -1
  21. package/dist/ai/components/agent/AgentMessageParts.js +1 -1
  22. package/dist/ai/strings.js +0 -1
  23. package/dist/ai/ui/sidebar.js +1 -1
  24. package/dist/components/Combobox.d.ts +19 -0
  25. package/dist/components/Combobox.js +101 -50
  26. package/dist/components/Drawer.d.ts +6 -1
  27. package/dist/components/Drawer.js +25 -16
  28. package/dist/components/Field.d.ts +5 -4
  29. package/dist/components/Field.js +27 -4
  30. package/dist/components/InfoTip.d.ts +12 -4
  31. package/dist/components/InfoTip.js +13 -3
  32. package/dist/components/MarkdownImpl.d.ts +1 -0
  33. package/dist/components/MarkdownImpl.js +1 -0
  34. package/dist/components/Menu.js +4 -4
  35. package/dist/components/MenuEntries.js +1 -1
  36. package/dist/components/OptionDescription.d.ts +7 -0
  37. package/dist/components/OptionDescription.js +19 -0
  38. package/dist/components/Popover.d.ts +4 -1
  39. package/dist/components/Popover.js +2 -1
  40. package/dist/components/Provider.d.ts +9 -0
  41. package/dist/components/Provider.js +4 -0
  42. package/dist/components/Select.js +13 -11
  43. package/dist/components/comboboxItems.d.ts +10 -0
  44. package/dist/components/comboboxItems.js +8 -1
  45. package/dist/components/floating.d.ts +5 -2
  46. package/dist/components/floating.js +2 -2
  47. package/dist/components/focus.d.ts +5 -0
  48. package/dist/components/focus.js +21 -1
  49. package/dist/editor/Editor.js +13 -3
  50. package/dist/editor/Monaco.d.ts +8 -1
  51. package/dist/editor/Monaco.js +16 -3
  52. package/dist/editor/editorModel.d.ts +1 -1
  53. package/dist/editor/editorModel.js +1 -1
  54. package/dist/editor/index.d.ts +1 -0
  55. package/dist/editor/index.js +2 -1
  56. package/dist/editor/languages.d.ts +24 -0
  57. package/dist/editor/languages.js +31 -0
  58. package/dist/editor/monacoFeatures.d.ts +7 -0
  59. package/dist/editor/monacoFeatures.js +19 -0
  60. package/dist/editor/themes.d.ts +5 -0
  61. package/dist/editor/themes.js +24 -4
  62. package/dist/editor/workers.d.ts +3 -3
  63. package/dist/editor/workers.js +8 -4
  64. package/dist/editor/yamlLanguage.js +55 -38
  65. package/dist/file-explorer/FileExplorer.d.ts +18 -4
  66. package/dist/file-explorer/FileExplorer.js +64 -20
  67. package/dist/file-explorer/FilePreview/Viewer.js +12 -8
  68. package/dist/file-explorer/FileRow.d.ts +6 -2
  69. package/dist/file-explorer/FileRow.js +35 -17
  70. package/dist/file-explorer/FileRowsWindow.d.ts +2 -1
  71. package/dist/file-explorer/FileRowsWindow.js +2 -1
  72. package/dist/file-explorer/FileTable.d.ts +3 -1
  73. package/dist/file-explorer/FileTable.js +3 -2
  74. package/dist/file-explorer/TreeView.d.ts +5 -1
  75. package/dist/file-explorer/TreeView.js +49 -25
  76. package/dist/file-explorer/helpers.js +3 -3
  77. package/dist/file-explorer/lib/paging.d.ts +1 -0
  78. package/dist/file-explorer/lib/paging.js +5 -1
  79. package/dist/file-explorer/lib/utils.d.ts +3 -0
  80. package/dist/file-explorer/lib/utils.js +6 -1
  81. package/dist/form/Wizard/utils.js +2 -1
  82. package/dist/list/ActionToolbar.js +3 -2
  83. package/dist/list/ListPage.js +1 -1
  84. package/dist/list/ListTable.d.ts +5 -0
  85. package/dist/list/ListTable.js +6 -2
  86. package/dist/styles/theme.css +82 -62
  87. package/dist/styles.css +1 -1
  88. package/dist/theme/derive.d.ts +5 -3
  89. package/dist/theme/derive.js +61 -3
  90. package/package.json +9 -2
  91. package/dist/file-explorer/FilePreview/CodePreviewEditor.d.ts +0 -6
  92. package/dist/file-explorer/FilePreview/CodePreviewEditor.js +0 -64
package/README.md CHANGED
@@ -11,9 +11,10 @@ so an application ships only what it imports:
11
11
  | `./list` | list pages, tables, row menus and list controls |
12
12
  | `./form` | `DynamicForm` and its field registry |
13
13
  | `./graph` | job dependency graph and tree view, and the visual workflow editor: the graph, the run form as an inputs editor, and their dialogs |
14
- | `./editor` | Monaco editor (`monaco-editor` is an optional peer); YAML validation, completion and hover run in a bundled worker |
14
+ | `./editor` | Monaco editor (`monaco-editor` is an optional peer), loaded on first mount with the languages it needs; YAML validation, completion and hover run in a bundled worker |
15
15
  | `./logviewer` | log viewer with ANSI color and search |
16
16
  | `./file-explorer` | object storage explorer over a host-supplied provider |
17
+ | `./access` | `AccessDrawer`: who has access to a resource, and granting roles to groups |
17
18
  | `./ai`, `./ai/openai`, `./ai/mock` | chat interface and adapters |
18
19
  | `./theme.css`, `./base.css`, `./fonts.css`, `./styles.css`, `./logviewer.css`, `./ai.css` | stylesheets |
19
20
 
@@ -63,7 +64,7 @@ Each need has one component. Reach for it before styling markup by hand.
63
64
  | One of a few choices, all shown | `RadioGroup` in a form; `SegmentedControl` in a toolbar, or as a field's control in a `Field group` |
64
65
  | Narrowing a list by values, such as statuses: several at once, or one or none | `ToggleGroup` |
65
66
  | Narrowing what is shown by typing | `SearchInput`; `ListSearchControl` on a list page's toolbar |
66
- | A value from a list | `Select`, or `Combobox` when the list needs filtering |
67
+ | A value from a list | `Select`, or `Combobox` when the list needs filtering; its `details` pane describes the highlighted item, and `onQueryChange` hands the search to the host |
67
68
  | Free text with suggestions, or tags | `Autocomplete`; `Combobox` with `multiple creatable` |
68
69
  | A range | `Slider` |
69
70
  | A page's breadcrumbs, title and commands | `PageHeader`, shown by the shell's `PageHeaderBar` (see [Page header](#page-header)) |
@@ -73,10 +74,11 @@ Each need has one component. Reach for it before styling markup by hand.
73
74
  | Views of one thing | `Tabs` |
74
75
  | A list of actions | `ActionMenu`; `useContextMenu` for a right-click or a row's menu |
75
76
  | A panel of controls anchored to a trigger | `Popover` |
76
- | A hint for a control | `Tooltip`; `InfoTip` for help everyone must reach |
77
+ | A hint for a control | `Tooltip`; a `Field`'s `help` for more about a field, which its control reads and which opens as Tab reaches it; `InfoTip` for help about anything else |
77
78
  | A preview of what a link leads to | `HoverCard` |
78
79
  | A task that blocks the page | `Modal`, laid out with `title`, `description` and `footer` as a `Drawer` is; `ConfirmModal` to confirm one action; `CreateModal` to name and create one thing |
79
- | A panel beside the page | `Drawer` |
80
+ | A panel beside the page | `Drawer`; `onBack` for a view it opens over its first, such as a form |
81
+ | Who has access to something, and changing it | `AccessDrawer` from `./access` (see [Access](#access)) |
80
82
  | A message set into the page: guidance, a warning, an error | `Callout`; `variant="bar"` across a surface under its header |
81
83
  | A passing notice, or the progress of work in the background | `toast` |
82
84
  | How full something is, how far a task has come | `Meter`, `ProgressBar` |
@@ -313,11 +315,13 @@ In React, `useColorMode(preference)` follows the device while the preference is
313
315
  `system`, and `useApplyTheme(theme, mode)` applies the theme to the document
314
316
  root (or to an element you pass) and sets `color-scheme`. `applyTheme` sets the
315
317
  display-p3 values on a wide-gamut screen. To render a theme on the server,
316
- `themeStylesheet(selector, scheme)` returns a stylesheet with both blocks.
318
+ `themeStylesheet(selector, scheme)` returns a stylesheet with both blocks; given
319
+ `{ light, dark }` schemes, it pairs each token's two values with `light-dark()`.
317
320
  `deriveTheme(scheme)` returns the sRGB tokens alone.
318
321
 
319
- `theme.css` defines the default theme's tokens, so a page renders before any
320
- script runs:
322
+ `theme.css` defines the default theme's tokens for both modes, each as
323
+ `light-dark()`, so a page renders before any script runs and a token the host
324
+ leaves unset follows the `color-scheme` it sets:
321
325
 
322
326
  ```css
323
327
  /* In a Tailwind v4 build: */
@@ -434,6 +438,7 @@ where it opens elsewhere, and renders it through `AppLink`:
434
438
  | A list row | `ListRow` `href` with a `NameCell` of the same `href`: the name's link covers the row |
435
439
  | A toast's action | `action: { label, href }` |
436
440
  | `SidebarRailItem` | `href`; `onSelect` then runs only on a plain click |
441
+ | `FileExplorer` rows, tree, trail and "Open folder" | `pathHref(path)`; `onPathChange` is then optional, and the explorer's own moves go to `navigation.goTo` |
437
442
  | `EmptyState`, `SettingsCard` actions | a `Button` with `href` |
438
443
 
439
444
  `AppLink` routes in-app URLs through the provider's `link` slot, so a host
@@ -469,6 +474,52 @@ five seconds, eight for a warning or an error, pausing while the pointer or focu
469
474
  is on it; `timeout: false` keeps it. `loading` stays until an update settles it
470
475
  into a tone. Pass an `id` to replace a toast rather than stack another.
471
476
 
477
+ ## Access
478
+
479
+ `AccessDrawer` shows who holds which role on one resource, and grants and takes
480
+ them away. A role is a named set of permissions; groups are granted roles, never
481
+ permissions one by one. Two roles may overlap: the drawer works the overlap out
482
+ from each role's `permissions` and says so as a hint ("Sudo covers this too"),
483
+ and before a role is taken away it lists exactly what the group loses, the
484
+ permissions no role it keeps still gives, and what it keeps.
485
+
486
+ ```tsx
487
+ import { AccessDrawer, type AccessChange } from '@parallelworks/ui/access'
488
+
489
+ <AccessDrawer
490
+ open={open}
491
+ onClose={close}
492
+ resourceName={cluster.name}
493
+ roles={[{ id: 'login', name: 'Login', description: 'Sign in.', permissions: ['cluster:login'] }]}
494
+ permissions={[{ id: 'cluster:login', label: 'Sign in' }]}
495
+ groups={[{ id: 'org', name: 'Everyone', kind: 'everyone', memberCount: 214 }, ...granted]}
496
+ grants={grants} // { groupId, roleId }[]
497
+ onChange={(change: AccessChange) => api.patch(change)} // { grant, revoke }
498
+ describeGroup={(id, signal) => api.group(id, { signal })}
499
+ searchGroups={(query, signal) => api.groups({ query, signal })}
500
+ readOnly={!canManage}
501
+ readOnlyReason="Only people with the Admin role can change it."
502
+ />
503
+ ```
504
+
505
+ - `onChange` gets one change at a time, holding only grants that change
506
+ something. Return a promise: its rows show they are saving until it settles,
507
+ and when it rejects the drawer says that nothing changed and offers Retry. A
508
+ saved change offers Undo in a toast. Pass the new grants back through
509
+ `grants`; the drawer never keeps its own copy.
510
+ - `groups` holds every group that has a role here, and everyone if the resource
511
+ can be granted to everyone (`kind: 'everyone'`, at most one). Without
512
+ `searchGroups` it is also the list to grant to; with it, the picker asks the
513
+ host as the reader types.
514
+ - `describeGroup` fills the pane beside the group picker for the highlighted
515
+ group: where it comes from, its first members and when it last changed. It is
516
+ asked once per group while the drawer is mounted, and aborted when the
517
+ highlight moves on.
518
+ - `highImpact` roles carry a tag, and granting anything to everyone says how
519
+ many people it reaches and asks to confirm.
520
+ - A development build warns about duplicate ids and grants that name unknown
521
+ roles or groups.
522
+
472
523
  ## Host chrome
473
524
 
474
525
  Popups, dialogs and drawers portal to `<body>` and read the tokens from the
@@ -611,6 +662,38 @@ Text that comes back unchanged leaves the cursor alone, and a reset from outside
611
662
  and scroll where they were. Each editor has a model of its own unless it is given a `path`, and it
612
663
  is named `UIStrings.common.codeEditor` unless given an `ariaLabel`.
613
664
 
665
+ Monaco comes from the host's own `monaco-editor` through its bundler, never from a CDN, and loads
666
+ when the first editor mounts, so it stays out of the host's first bundle. Its workers are files in
667
+ the package that the host's bundler copies, started with `new Worker(url)`: a CSP needs
668
+ `worker-src 'self'` and no `blob:`. The YAML language server starts with the first YAML model.
669
+
670
+ The editor colors every language Monaco has, Bash among them (as shell), and fetches each
671
+ language's coloring only when an editor shows it. It validates and formats JSON. The TypeScript,
672
+ CSS and HTML language services are left out, as each runs in a worker of megabytes; their text is
673
+ still colored. `addEditorLanguages` adds a language service and its workers, by the label Monaco
674
+ asks for, or a language of the host's own. It loads with the next editor to mount; call it once
675
+ at startup. With Vite:
676
+
677
+ ```ts
678
+ import { addEditorLanguages } from '@parallelworks/ui/editor'
679
+
680
+ const tsWorker = () =>
681
+ new Worker(new URL('monaco-editor/language/typescript/ts.worker.js', import.meta.url), {
682
+ type: 'module',
683
+ })
684
+ // The TypeScript service checks JavaScript too, and asks for a worker under each label.
685
+ addEditorLanguages({
686
+ load: () => import('monaco-editor/languages/features/typescript/register'),
687
+ workers: { typescript: tsWorker, javascript: tsWorker },
688
+ })
689
+ ```
690
+
691
+ `readOnly` makes it a view of code to read, such as a file's contents: the text stays selectable,
692
+ copyable and reachable by keyboard and is announced read-only, while the cursor, current line and
693
+ quick fixes are hidden, the view ends at the last line, and the wheel scrolls the page once the text
694
+ has scrolled to its end. Any of these can be set otherwise with Monaco's own options. `disabled`
695
+ greys the text out as well, as a disabled field does.
696
+
614
697
  ## AI chat
615
698
 
616
699
  `@parallelworks/ui/ai` is a chat interface (thread, composer, streaming,
@@ -674,6 +757,12 @@ animations) and streamdown's classes next to the theme contract:
674
757
 
675
758
  The prebuilt `@parallelworks/ui/styles.css` already includes both.
676
759
 
760
+ Math needs nothing from the host: `Markdown` imports KaTeX's stylesheet
761
+ itself, from the `katex` package, so the host's bundler loads it and its fonts
762
+ with the markdown renderer's lazy chunk. The bundler must accept CSS imported
763
+ from JavaScript, as the editor's Monaco also requires. Inline math takes `$$`
764
+ within a sentence; a single `$` stays text, so prices are never read as math.
765
+
677
766
  ## Form state
678
767
 
679
768
  `DynamicForm` keeps its values in react-hook-form, a peer dependency, so the
@@ -775,7 +864,7 @@ controls. A label that wraps to two lines sits at the foot of a label row the
775
864
  fields in that row share, so its control stays level with its neighbors'.
776
865
  Set `align: rows` when every immediate child is a
777
866
  section. Adjacent sections then share a header row and one row per direct child.
778
- A description, help text, or validation message increases the shared row height,
867
+ A description or validation message increases the shared row height,
779
868
  so the following rows stay aligned. A nested grid counts as one child row.
780
869
  The grid's gap controls shared row spacing. Hidden children retain their row slot
781
870
  while a neighboring section uses it; empty sections disappear. When the configured
@@ -0,0 +1,55 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AccessModel } from './model.js';
3
+ import { type AccessStrings } from './strings.js';
4
+ import type { AccessChange, AccessGrant, AccessGroup, AccessGroupDetails, AccessPermission, AccessRole } from './types.js';
5
+ export type AccessArrangement = 'role' | 'group';
6
+ export interface AccessDrawerProps {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ /** What access is to, as the reader knows it: "gpu-cluster-east". */
10
+ resourceName: string;
11
+ /** Every role that can be granted here, in the order to list them. */
12
+ roles: readonly AccessRole[];
13
+ /** Every group that holds a role here, and everyone when the resource can be granted to
14
+ * everyone. Without `searchGroups`, also every group that can be granted one. */
15
+ groups: readonly AccessGroup[];
16
+ /** Who holds which role. The drawer shows these and never changes them itself. */
17
+ grants: readonly AccessGrant[];
18
+ /** Names the permissions roles list, for what a group loses when it loses a role. */
19
+ permissions?: readonly AccessPermission[] | undefined;
20
+ /** Saves a change. Return a promise that settles once it is saved: until then its rows show
21
+ * that they are saving, and when it rejects the drawer says that nothing changed and offers
22
+ * to try again. Pass the new grants back through `grants`. */
23
+ onChange?: ((change: AccessChange) => void | Promise<unknown>) | undefined;
24
+ /** The grants are still loading: skeletons hold their place. */
25
+ loading?: boolean | undefined;
26
+ /** Shows who has access without letting the reader change it. */
27
+ readOnly?: boolean | undefined;
28
+ /** Who can change it, said in read-only mode: "Only people with the Admin role can change it." */
29
+ readOnlyReason?: ReactNode;
30
+ /** More about a group, asked for when it is highlighted in the group picker and kept while the
31
+ * drawer is mounted. A highlight that moves on aborts `signal`. */
32
+ describeGroup?: ((groupId: string, signal: AbortSignal) => Promise<AccessGroupDetails>) | undefined;
33
+ /** Finds groups to grant to, for an organization with too many to pass in `groups`. A newer
34
+ * query aborts `signal`. Without it, the picker filters `groups`. */
35
+ searchGroups?: ((query: string, signal: AbortSignal) => Promise<readonly AccessGroup[]>) | undefined;
36
+ /** How many groups the organization has, for the picker's hint and heading. */
37
+ groupCount?: number | undefined;
38
+ /** Groups the reader granted to lately, listed first in the picker. */
39
+ recentGroupIds?: readonly string[] | undefined;
40
+ /** How the list starts out: role by role, or group by group. */
41
+ defaultArrangement?: AccessArrangement | undefined;
42
+ /** As `Drawer`'s. */
43
+ width?: number | string | undefined;
44
+ }
45
+ /** Who has access to one resource, and the place to change it: roles granted to groups.
46
+ * Overlapping roles are allowed and only redundant, which the drawer points out; taking a
47
+ * role away first says exactly what the group loses. Each change is saved as it is made, with
48
+ * Undo in a toast. */
49
+ export declare function AccessDrawer({ open, onClose, resourceName, roles, groups, grants, permissions, onChange, loading, readOnly, readOnlyReason, describeGroup, searchGroups, groupCount, recentGroupIds, defaultArrangement, width, }: AccessDrawerProps): import("react").JSX.Element;
50
+ type Match = (query: string, ...texts: (string | undefined)[]) => boolean;
51
+ export declare function filterAccess(model: AccessModel, arrangement: AccessArrangement, query: string, match: Match, t: AccessStrings): {
52
+ shown: Map<string, readonly string[]>;
53
+ order: string[];
54
+ };
55
+ export {};
@@ -0,0 +1,445 @@
1
+ import { AddIcon, LockIcon, UsersIcon } from "../icons.js";
2
+ import { toast } from "../components/Toast.js";
3
+ import { EmptyState } from "../components/EmptyState.js";
4
+ import { useQueryMatch } from "../components/search.js";
5
+ import { Button } from "../components/Button.js";
6
+ import { Callout } from "../components/Callout.js";
7
+ import { Drawer } from "../components/Drawer.js";
8
+ import { LoadingRegion, Skeleton } from "../components/Skeleton.js";
9
+ import { SearchInput } from "../components/SearchInput.js";
10
+ import { SegmentedControl } from "../components/SegmentedControl.js";
11
+ import { buildModel, invert, key, warnAboutMisuse } from "./model.js";
12
+ import { ByGroup, ByRole, removeKey } from "./AccessList.js";
13
+ import { holdFocus, useFocusAfterRender } from "./focus.js";
14
+ import { useGrantView } from "./GrantView.js";
15
+ import { useAccessStrings } from "./strings.js";
16
+ import { jsx, jsxs } from "react/jsx-runtime";
17
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
18
+ //#region src/access/AccessDrawer.tsx
19
+ var EMPTY_SELECTION = {
20
+ groupIds: [],
21
+ roleIds: []
22
+ };
23
+ /** Who has access to one resource, and the place to change it: roles granted to groups.
24
+ * Overlapping roles are allowed and only redundant, which the drawer points out; taking a
25
+ * role away first says exactly what the group loses. Each change is saved as it is made, with
26
+ * Undo in a toast. */
27
+ function AccessDrawer({ open, onClose, resourceName, roles, groups, grants, permissions, onChange, loading = false, readOnly = false, readOnlyReason, describeGroup, searchGroups, groupCount, recentGroupIds, defaultArrangement = "role", width = 560 }) {
28
+ const t = useAccessStrings();
29
+ const uid = useId();
30
+ const model = useMemo(() => buildModel(roles, groups, grants, permissions), [
31
+ roles,
32
+ groups,
33
+ grants,
34
+ permissions
35
+ ]);
36
+ useEffect(() => {
37
+ if (!loading) warnAboutMisuse(roles, groups, grants, permissions);
38
+ }, [
39
+ roles,
40
+ groups,
41
+ grants,
42
+ permissions,
43
+ loading
44
+ ]);
45
+ const [view, setView] = useState("list");
46
+ const [selection, setSelection] = useState(EMPTY_SELECTION);
47
+ const [arrangement, setArrangement] = useState(defaultArrangement);
48
+ const [query, setQuery] = useState("");
49
+ const [expanded, setExpanded] = useState({
50
+ role: /* @__PURE__ */ new Set(),
51
+ group: /* @__PURE__ */ new Set()
52
+ });
53
+ const [removal, setRemoval] = useState();
54
+ const [saving, setSaving] = useState();
55
+ const [failure, setFailure] = useState();
56
+ const cache = useRef(/* @__PURE__ */ new Map()).current;
57
+ const seen = useRef(/* @__PURE__ */ new Map()).current;
58
+ const body = useRef(null);
59
+ const [bodyElement, setBodyElement] = useState(null);
60
+ const bodyRef = useCallback((element) => {
61
+ body.current = element;
62
+ setBodyElement(element);
63
+ }, []);
64
+ const focusLater = useFocusAfterRender();
65
+ const change = useRef(onChange);
66
+ change.current = onChange;
67
+ const match = useQueryMatch();
68
+ const filtered = useFiltered(model, arrangement, query.trim(), match, t);
69
+ const editable = !readOnly && onChange !== void 0;
70
+ const sectionId = (id) => `${uid}${arrangement}${id}`;
71
+ const names = (changed) => {
72
+ const all = [...changed.grant, ...changed.revoke];
73
+ return {
74
+ groupNames: [...new Set(all.map((grant) => grant.groupId))].map((id) => {
75
+ const group = seen.get(id) ?? model.groupById.get(id);
76
+ return group?.kind === "everyone" ? t.everyone : group?.name ?? id;
77
+ }),
78
+ roleNames: [...new Set(all.map((grant) => grant.roleId))].map((id) => model.roleById.get(id)?.name ?? id)
79
+ };
80
+ };
81
+ const undo = (done) => {
82
+ Promise.resolve(change.current?.(invert(done))).then(() => toast.info(t.undone), (error) => toast.error(t.nothingChanged(messageOf(error)), { title: t.undoFailed }));
83
+ };
84
+ /** Saves a change and reports how it went; resolves true once saved. */
85
+ const save = async (next, where) => {
86
+ if (!onChange || next.grant.length === 0 && next.revoke.length === 0) return false;
87
+ const { groupNames, roleNames } = names(next);
88
+ setSaving(next);
89
+ setFailure(void 0);
90
+ try {
91
+ await onChange(next);
92
+ } catch (error) {
93
+ setFailure({
94
+ change: next,
95
+ title: next.grant.length > 0 ? t.grantFailed(groupNames, roleNames) : t.removeFailed(groupNames[0] ?? "", roleNames),
96
+ message: messageOf(error),
97
+ ...where
98
+ });
99
+ return false;
100
+ } finally {
101
+ setSaving(void 0);
102
+ }
103
+ toast.success(next.grant.length > 0 ? t.granted(groupNames, roleNames) : t.removed(groupNames, roleNames), { action: {
104
+ label: t.common.undo,
105
+ onClick: () => undo(next)
106
+ } });
107
+ return true;
108
+ };
109
+ const toList = () => {
110
+ setView("list");
111
+ setFailure((current) => current?.inGrant ? void 0 : current);
112
+ focusLater(() => visible(body.current?.querySelectorAll("[data-grant-access]")));
113
+ };
114
+ const toGrant = (groupIds = []) => {
115
+ setSelection({
116
+ groupIds,
117
+ roleIds: []
118
+ });
119
+ setRemoval(void 0);
120
+ setView("grant");
121
+ focusLater(() => body.current?.querySelector("input[role=\"combobox\"]"));
122
+ };
123
+ const grant = async (next) => {
124
+ if (await save(next, {
125
+ inGrant: true,
126
+ groupId: next.grant[0]?.groupId ?? ""
127
+ })) {
128
+ setExpanded((current) => ({
129
+ role: /* @__PURE__ */ new Set([...current.role, ...next.grant.map((g) => g.roleId)]),
130
+ group: /* @__PURE__ */ new Set([...current.group, ...next.grant.map((g) => g.groupId)])
131
+ }));
132
+ toList();
133
+ }
134
+ };
135
+ const remove = async (pending) => {
136
+ const next = {
137
+ grant: [],
138
+ revoke: pending.roleIds.map((roleId) => ({
139
+ groupId: pending.groupId,
140
+ roleId
141
+ }))
142
+ };
143
+ const anchor = arrangement === "role" && !pending.all ? pending.roleIds[0] : void 0;
144
+ await save(next, {
145
+ inGrant: false,
146
+ groupId: pending.groupId,
147
+ roleId: anchor
148
+ });
149
+ setRemoval(void 0);
150
+ focusLater(() => document.getElementById(sectionId(anchor ?? pending.groupId)) ?? body.current?.querySelector("input[type=\"search\"]"));
151
+ };
152
+ const retry = (failed) => failed.inGrant ? grant(failed.change) : save(failed.change, {
153
+ inGrant: false,
154
+ groupId: failed.groupId,
155
+ roleId: failed.roleId
156
+ });
157
+ const failureNode = failure && /* @__PURE__ */ jsx(Callout, {
158
+ tone: "error",
159
+ live: true,
160
+ title: failure.title,
161
+ action: /* @__PURE__ */ jsx(Button, {
162
+ size: "sm",
163
+ variant: "secondary",
164
+ onClick: () => retry(failure),
165
+ children: t.common.retry
166
+ }),
167
+ onDismiss: () => setFailure(void 0),
168
+ children: t.nothingChanged(failure.message)
169
+ });
170
+ const close = () => {
171
+ setView("list");
172
+ setRemoval(void 0);
173
+ setQuery("");
174
+ onClose();
175
+ };
176
+ const grantView = useGrantView({
177
+ model,
178
+ selection,
179
+ onSelectionChange: setSelection,
180
+ resourceName,
181
+ describeGroup,
182
+ searchGroups,
183
+ groupCount,
184
+ recentGroupIds,
185
+ cache,
186
+ seen,
187
+ saving: saving !== void 0,
188
+ failure: failure?.inGrant ? failureNode : void 0,
189
+ onGrant: grant,
190
+ onCancel: toList,
191
+ focusLater,
192
+ boundary: bodyElement,
193
+ t
194
+ });
195
+ const savingKeys = useMemo(() => new Set(saving?.revoke.map(key) ?? []), [saving]);
196
+ const empty = !loading && model.held.size === 0;
197
+ const View = arrangement === "role" ? ByRole : ByGroup;
198
+ const grantButton = (className) => /* @__PURE__ */ jsx(Button, {
199
+ variant: "primary",
200
+ icon: /* @__PURE__ */ jsx(AddIcon, {}),
201
+ "data-grant-access": "",
202
+ disabled: loading,
203
+ onClick: () => toGrant(),
204
+ className,
205
+ children: t.grantAccess
206
+ });
207
+ const list = /* @__PURE__ */ jsxs("div", {
208
+ className: "@container/access flex min-h-0 flex-1 flex-col",
209
+ children: [
210
+ readOnly && /* @__PURE__ */ jsx("div", {
211
+ className: "px-4 pt-3",
212
+ children: /* @__PURE__ */ jsxs(Callout, {
213
+ tone: "info",
214
+ icon: /* @__PURE__ */ jsx(LockIcon, {}),
215
+ children: [
216
+ t.readOnly,
217
+ " ",
218
+ readOnlyReason ?? t.readOnlyReason
219
+ ]
220
+ })
221
+ }),
222
+ !empty && /* @__PURE__ */ jsxs("div", {
223
+ className: "flex flex-wrap items-center gap-2 border-b border-(--theme-border) px-4 py-3",
224
+ children: [
225
+ /* @__PURE__ */ jsx("div", {
226
+ className: "w-full @min-[30rem]/access:w-auto",
227
+ children: /* @__PURE__ */ jsx(SegmentedControl, {
228
+ fill: true,
229
+ label: t.arrange,
230
+ value: arrangement,
231
+ onValueChange: setArrangement,
232
+ options: [{
233
+ value: "role",
234
+ label: t.byRole
235
+ }, {
236
+ value: "group",
237
+ label: t.byGroup
238
+ }]
239
+ })
240
+ }),
241
+ /* @__PURE__ */ jsx(SearchInput, {
242
+ label: t.filter,
243
+ value: query,
244
+ onValueChange: (next) => {
245
+ setQuery(next);
246
+ setExpanded(expandedFor(model, next.trim(), match, t));
247
+ },
248
+ className: "min-w-0 flex-1 basis-full @min-[30rem]/access:basis-0"
249
+ }),
250
+ editable && grantButton("hidden @min-[30rem]/access:inline-flex")
251
+ ]
252
+ }),
253
+ failure && !failure.inGrant && !filtered.order.includes(anchorOf(failure, arrangement)) && /* @__PURE__ */ jsx("div", {
254
+ className: "px-4 pt-3",
255
+ children: failureNode
256
+ }),
257
+ /* @__PURE__ */ jsx("div", {
258
+ className: "min-h-0 flex-1 overflow-y-auto",
259
+ children: loading ? /* @__PURE__ */ jsx(LoadingRegion, {
260
+ label: t.loading,
261
+ children: /* @__PURE__ */ jsx(RowsSkeleton, {})
262
+ }) : empty ? /* @__PURE__ */ jsx(EmptyState, {
263
+ variant: "bare",
264
+ icon: /* @__PURE__ */ jsx(UsersIcon, {}),
265
+ title: t.emptyTitle,
266
+ description: t.emptyDescription(resourceName),
267
+ action: editable && grantButton(""),
268
+ className: "py-16"
269
+ }) : filtered.order.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
270
+ variant: "bare",
271
+ title: t.noMatches,
272
+ action: /* @__PURE__ */ jsx(Button, {
273
+ variant: "secondary",
274
+ size: "sm",
275
+ onClick: () => setQuery(""),
276
+ children: t.clearFilter
277
+ })
278
+ }) : /* @__PURE__ */ jsx(View, {
279
+ model,
280
+ shown: filtered.shown,
281
+ order: filtered.order,
282
+ expanded: expanded[arrangement],
283
+ onExpandedChange: (id, isOpen) => setExpanded((current) => {
284
+ const next = new Set(current[arrangement]);
285
+ if (isOpen) next.add(id);
286
+ else next.delete(id);
287
+ return {
288
+ ...current,
289
+ [arrangement]: next
290
+ };
291
+ }),
292
+ filtering: query.trim() !== "",
293
+ sectionId,
294
+ t,
295
+ actions: {
296
+ readOnly: !editable,
297
+ saving: savingKeys,
298
+ removal,
299
+ onAskRemove: (next) => {
300
+ holdFocus();
301
+ setFailure(void 0);
302
+ setRemoval(next);
303
+ },
304
+ onCancelRemove: () => {
305
+ const asked = removal;
306
+ setRemoval(void 0);
307
+ if (asked) focusLater(() => body.current?.querySelector(`[data-remove="${CSS.escape(removeKey(asked))}"]`));
308
+ },
309
+ onRemove: remove,
310
+ onGrantMore: (groupId) => toGrant([groupId]),
311
+ failure: failure && !failure.inGrant ? {
312
+ node: failureNode,
313
+ groupId: failure.groupId,
314
+ roleId: failure.roleId
315
+ } : void 0
316
+ }
317
+ })
318
+ }),
319
+ editable && !empty && /* @__PURE__ */ jsx("div", {
320
+ className: "border-t border-(--theme-border) p-4 @min-[30rem]/access:hidden",
321
+ children: grantButton("w-full")
322
+ })
323
+ ]
324
+ });
325
+ const granting = view === "grant";
326
+ return /* @__PURE__ */ jsx(Drawer, {
327
+ open,
328
+ onClose: close,
329
+ width,
330
+ padded: false,
331
+ title: granting ? t.grantAccess : t.title,
332
+ description: granting ? resourceName : loading ? resourceName : t.summary(resourceName, [...model.held.keys()].filter((id) => id !== model.everyone?.id).length, model.everyone !== void 0 && model.held.has(model.everyone.id)),
333
+ onBack: granting ? toList : void 0,
334
+ backLabel: t.back,
335
+ footer: granting ? grantView.footer : void 0,
336
+ onKeyDown: (e) => {
337
+ const target = e.target;
338
+ if (granting && e.key === "Escape" && !e.defaultPrevented && target.getAttribute("aria-expanded") !== "true") {
339
+ e.stopPropagation();
340
+ e.preventBaseUIHandler();
341
+ toList();
342
+ }
343
+ },
344
+ children: /* @__PURE__ */ jsx("div", {
345
+ ref: bodyRef,
346
+ className: "flex min-h-0 flex-1 flex-col",
347
+ children: granting ? grantView.body : list
348
+ })
349
+ });
350
+ }
351
+ var messageOf = (error) => error instanceof Error && error.message ? error.message : void 0;
352
+ var anchorOf = (failure, arrangement) => arrangement === "role" ? failure.roleId ?? "" : failure.groupId;
353
+ /** The first of several elements that is laid out, such as the one of two buttons a width shows. */
354
+ /** The one of `elements` that is laid out, or the first where nothing is (as in tests). */
355
+ function visible(elements) {
356
+ const all = [...elements ?? []];
357
+ return all.find((element) => element.getClientRects().length > 0) ?? all[0];
358
+ }
359
+ var groupNames = (group, t) => group.kind === "everyone" ? [
360
+ group.name,
361
+ t.everyone,
362
+ t.everyoneInOrganization
363
+ ] : [group.name];
364
+ /** The sections in view and the rows under each. A section named by the query shows all its
365
+ * rows; otherwise only the rows the query names, and no section when none are. */
366
+ function useFiltered(model, arrangement, query, match, t) {
367
+ return useMemo(() => filterAccess(model, arrangement, query, match, t), [
368
+ model,
369
+ arrangement,
370
+ query,
371
+ match,
372
+ t
373
+ ]);
374
+ }
375
+ function filterAccess(model, arrangement, query, match, t) {
376
+ const shown = /* @__PURE__ */ new Map();
377
+ const order = [];
378
+ if (arrangement === "role") for (const role of model.roles) {
379
+ const holders = model.holders.get(role.id) ?? [];
380
+ const rows = !query || match(query, role.name, role.description) ? holders : holders.filter((group) => match(query, ...groupNames(group, t)));
381
+ if (!query || rows.length > 0) {
382
+ order.push(role.id);
383
+ shown.set(role.id, rows.map((group) => group.id));
384
+ }
385
+ }
386
+ else {
387
+ const everyone = model.everyone;
388
+ const holders = [...model.held.keys()].sort((a, b) => a === everyone?.id ? -1 : b === everyone?.id ? 1 : 0);
389
+ for (const groupId of holders) {
390
+ const group = model.groupById.get(groupId);
391
+ const held = model.held.get(groupId) ?? [];
392
+ if (!group) continue;
393
+ const rows = !query || match(query, ...groupNames(group, t)) ? held : held.filter((role) => match(query, role.name, role.description));
394
+ if (!query || rows.length > 0) {
395
+ order.push(groupId);
396
+ shown.set(groupId, rows.map((role) => role.id));
397
+ }
398
+ }
399
+ }
400
+ return {
401
+ shown,
402
+ order
403
+ };
404
+ }
405
+ /** Opens the sections a query found rows in, so the matches show; each can then be folded. */
406
+ function expandedFor(model, query, match, t) {
407
+ if (!query) return {
408
+ role: /* @__PURE__ */ new Set(),
409
+ group: /* @__PURE__ */ new Set()
410
+ };
411
+ const open = (arrangement) => {
412
+ const { shown } = filterAccess(model, arrangement, query, match, t);
413
+ return new Set([...shown].filter(([, rows]) => rows.length > 0).map(([id]) => id));
414
+ };
415
+ return {
416
+ role: open("role"),
417
+ group: open("group")
418
+ };
419
+ }
420
+ function RowsSkeleton() {
421
+ return [
422
+ "a",
423
+ "b",
424
+ "c",
425
+ "d"
426
+ ].map((row) => /* @__PURE__ */ jsxs("div", {
427
+ className: "flex items-center gap-3 border-b border-(--theme-border) px-4 py-3",
428
+ children: [
429
+ /* @__PURE__ */ jsx(Skeleton, {
430
+ width: 16,
431
+ height: 16
432
+ }),
433
+ /* @__PURE__ */ jsxs("div", {
434
+ className: "flex min-w-0 flex-1 flex-col gap-1.5",
435
+ children: [/* @__PURE__ */ jsx(Skeleton, { width: "30%" }), /* @__PURE__ */ jsx(Skeleton, {
436
+ width: "60%",
437
+ className: "text-sm"
438
+ })]
439
+ }),
440
+ /* @__PURE__ */ jsx(Skeleton, { width: 56 })
441
+ ]
442
+ }, row));
443
+ }
444
+ //#endregion
445
+ export { AccessDrawer, filterAccess };