@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.
- package/README.md +97 -8
- package/dist/access/AccessDrawer.d.ts +55 -0
- package/dist/access/AccessDrawer.js +445 -0
- package/dist/access/AccessList.d.ts +58 -0
- package/dist/access/AccessList.js +382 -0
- package/dist/access/GrantView.d.ts +35 -0
- package/dist/access/GrantView.js +188 -0
- package/dist/access/GroupPicker.d.ts +35 -0
- package/dist/access/GroupPicker.js +336 -0
- package/dist/access/focus.d.ts +10 -0
- package/dist/access/focus.js +27 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.js +3 -0
- package/dist/access/model.d.ts +49 -0
- package/dist/access/model.js +129 -0
- package/dist/access/strings.d.ts +122 -0
- package/dist/access/strings.js +151 -0
- package/dist/access/types.d.ts +56 -0
- package/dist/ai/ai.css +5 -3
- package/dist/ai/components/SlashMenu.js +1 -1
- package/dist/ai/components/agent/AgentMessageParts.js +1 -1
- package/dist/ai/strings.js +0 -1
- package/dist/ai/ui/sidebar.js +1 -1
- package/dist/components/Combobox.d.ts +19 -0
- package/dist/components/Combobox.js +101 -50
- package/dist/components/Drawer.d.ts +6 -1
- package/dist/components/Drawer.js +25 -16
- package/dist/components/Field.d.ts +5 -4
- package/dist/components/Field.js +27 -4
- package/dist/components/InfoTip.d.ts +12 -4
- package/dist/components/InfoTip.js +13 -3
- package/dist/components/MarkdownImpl.d.ts +1 -0
- package/dist/components/MarkdownImpl.js +1 -0
- package/dist/components/Menu.js +4 -4
- package/dist/components/MenuEntries.js +1 -1
- package/dist/components/OptionDescription.d.ts +7 -0
- package/dist/components/OptionDescription.js +19 -0
- package/dist/components/Popover.d.ts +4 -1
- package/dist/components/Popover.js +2 -1
- package/dist/components/Provider.d.ts +9 -0
- package/dist/components/Provider.js +4 -0
- package/dist/components/Select.js +13 -11
- package/dist/components/comboboxItems.d.ts +10 -0
- package/dist/components/comboboxItems.js +8 -1
- package/dist/components/floating.d.ts +5 -2
- package/dist/components/floating.js +2 -2
- package/dist/components/focus.d.ts +5 -0
- package/dist/components/focus.js +21 -1
- package/dist/editor/Editor.js +13 -3
- package/dist/editor/Monaco.d.ts +8 -1
- package/dist/editor/Monaco.js +16 -3
- package/dist/editor/editorModel.d.ts +1 -1
- package/dist/editor/editorModel.js +1 -1
- package/dist/editor/index.d.ts +1 -0
- package/dist/editor/index.js +2 -1
- package/dist/editor/languages.d.ts +24 -0
- package/dist/editor/languages.js +31 -0
- package/dist/editor/monacoFeatures.d.ts +7 -0
- package/dist/editor/monacoFeatures.js +19 -0
- package/dist/editor/themes.d.ts +5 -0
- package/dist/editor/themes.js +24 -4
- package/dist/editor/workers.d.ts +3 -3
- package/dist/editor/workers.js +8 -4
- package/dist/editor/yamlLanguage.js +55 -38
- package/dist/file-explorer/FileExplorer.d.ts +18 -4
- package/dist/file-explorer/FileExplorer.js +64 -20
- package/dist/file-explorer/FilePreview/Viewer.js +12 -8
- package/dist/file-explorer/FileRow.d.ts +6 -2
- package/dist/file-explorer/FileRow.js +35 -17
- package/dist/file-explorer/FileRowsWindow.d.ts +2 -1
- package/dist/file-explorer/FileRowsWindow.js +2 -1
- package/dist/file-explorer/FileTable.d.ts +3 -1
- package/dist/file-explorer/FileTable.js +3 -2
- package/dist/file-explorer/TreeView.d.ts +5 -1
- package/dist/file-explorer/TreeView.js +49 -25
- package/dist/file-explorer/helpers.js +3 -3
- package/dist/file-explorer/lib/paging.d.ts +1 -0
- package/dist/file-explorer/lib/paging.js +5 -1
- package/dist/file-explorer/lib/utils.d.ts +3 -0
- package/dist/file-explorer/lib/utils.js +6 -1
- package/dist/form/Wizard/utils.js +2 -1
- package/dist/list/ActionToolbar.js +3 -2
- package/dist/list/ListPage.js +1 -1
- package/dist/list/ListTable.d.ts +5 -0
- package/dist/list/ListTable.js +6 -2
- package/dist/styles/theme.css +82 -62
- package/dist/styles.css +1 -1
- package/dist/theme/derive.d.ts +5 -3
- package/dist/theme/derive.js +61 -3
- package/package.json +9 -2
- package/dist/file-explorer/FilePreview/CodePreviewEditor.d.ts +0 -6
- 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
|
|
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
|
|
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
|
|
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 };
|