Initial Commit
This commit is contained in:
127
packages/networking/drive/drive-react-sharing/README.md
Normal file
127
packages/networking/drive/drive-react-sharing/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# @suaveplan/drive-react-sharing - React UI for @suaveplan/drive-sharing: ShareDialog, PartitionBadge, FileAccessList components and useSharing hook wired to the real sharing engine.
|
||||
|
||||
## Summary
|
||||
|
||||
React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled
|
||||
components plus a real-engine-backed hook.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
|
||||
<!-- end:auto-docs --> - [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
|
||||
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
|
||||
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
|
||||
- [file-access-list](./src/components/file-access-list/file-access-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-badge](./src/components/partition-badge/partition-badge.md) — <!-- begin:auto-docs --> _(no submodules)_- [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_ - [share-dialog](./src/components/share-dialog/share-dialog.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-sharing](./src/hooks/use-sharing/use-sharing.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `@suaveplan/drive-react-sharing` raises every failure as a `DriveReactSharingError` — a `SuaveplanError` subclass with…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/drive-react-sharing @suaveplan/drive-sharing
|
||||
```
|
||||
|
||||
Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import {
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
PartitionManager,
|
||||
TokenManager,
|
||||
userId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
import {
|
||||
ShareDialog,
|
||||
useSharing,
|
||||
} from "@suaveplan/drive-react-sharing";
|
||||
import { useState } from "react";
|
||||
|
||||
const partitions = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
const grants = new GrantManager();
|
||||
const tokens = new TokenManager();
|
||||
|
||||
function Trigger({ partitionId }: { partitionId: string }) {
|
||||
const sharing = useSharing({
|
||||
partitionId,
|
||||
grantorId: userId("me"),
|
||||
partitions,
|
||||
grants,
|
||||
tokens,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>Share</button>
|
||||
<ShareDialog
|
||||
open={open}
|
||||
recipients={sharing.recipients}
|
||||
loading={sharing.loading}
|
||||
errorMessage={sharing.error?.message}
|
||||
onAdd={(id, perm) => sharing.addRecipient(id, perm)}
|
||||
onRemove={(id) => sharing.removeRecipient(id)}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Purpose | Docs |
|
||||
|---|---|---|
|
||||
| `<ShareDialog />` | Modal share UI with add form + revoke per row | `src/components/share-dialog/share-dialog.md` |
|
||||
| `<PartitionBadge />` | Inline badge: classification + encryption indicator | `src/components/partition-badge/partition-badge.md` |
|
||||
| `<FileAccessList />` | Read-only access list with optional revoke | `src/components/file-access-list/file-access-list.md` |
|
||||
|
||||
## Hook
|
||||
|
||||
| Hook | Purpose | Docs |
|
||||
|---|---|---|
|
||||
| `useSharing(options)` | Live-engine binding for recipient state + add/remove/update/refresh actions | `src/hooks/use-sharing/use-sharing.md` |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- `<ShareDialog />` renders `role="dialog"` with `aria-modal="true"`
|
||||
and `aria-labelledby` pointing at the title heading. Focus moves to
|
||||
the recipient input on open; ESC and backdrop click both close.
|
||||
- `<FileAccessList />` renders a semantic `<ul role="list">` with one
|
||||
`<li role="listitem">` per recipient. Revoke is a native `<button>`
|
||||
with `aria-label` including the recipient identifier. Delete and
|
||||
Backspace fire revoke directly when the button is focused.
|
||||
- `<PartitionBadge />` renders `role="status"` and an `aria-label`
|
||||
describing the partition type plus encryption state. The padlock
|
||||
SVG is marked `aria-hidden="true"`.
|
||||
|
||||
## OTel
|
||||
|
||||
`useSharing` emits three counters:
|
||||
|
||||
- `drive.react.sharing.recipients_added_total{permission}`
|
||||
- `drive.react.sharing.recipients_removed_total`
|
||||
- `drive.react.sharing.permissions_updated_total{permission}`
|
||||
|
||||
## Type consolidation
|
||||
|
||||
Main shipped three overlapping permission aliases
|
||||
(`SharePermission` / `AccessPermission` / `SharingPermission`).
|
||||
imports `SharePermission` directly from
|
||||
`@suaveplan/drive-sharing` and re-exports it as the single canonical
|
||||
name. Aliases were not ported.
|
||||
|
||||
99
packages/networking/drive/drive-react-sharing/package.json
Normal file
99
packages/networking/drive/drive-react-sharing/package.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "@suaveplan/drive-react-sharing",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "React UI for @suaveplan/drive-sharing: ShareDialog, PartitionBadge, FileAccessList components and useSharing hook wired to the real sharing engine.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"browser": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./hooks/use-sharing": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/use-sharing/use-sharing.d.ts",
|
||||
"default": "./dist/hooks/use-sharing/use-sharing.js"
|
||||
}
|
||||
},
|
||||
"./components/share-dialog": {
|
||||
"import": {
|
||||
"types": "./dist/components/share-dialog/share-dialog.d.ts",
|
||||
"default": "./dist/components/share-dialog/share-dialog.js"
|
||||
}
|
||||
},
|
||||
"./components/partition-badge": {
|
||||
"import": {
|
||||
"types": "./dist/components/partition-badge/partition-badge.d.ts",
|
||||
"default": "./dist/components/partition-badge/partition-badge.js"
|
||||
}
|
||||
},
|
||||
"./components/file-access-list": {
|
||||
"import": {
|
||||
"types": "./dist/components/file-access-list/file-access-list.d.ts",
|
||||
"default": "./dist/components/file-access-list/file-access-list.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ && bun run ../../../../scripts/lint/check-no-backwards-deps.ts --package-json ./package.json",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/drive-sharing": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-dom": "workspace:^",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# `<FileAccessList />`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<FileAccessList />` renders a read-only list of share recipients with
|
||||
an optional revoke action per row. It is the lightweight surface used
|
||||
inside file detail panels and partition sidebars where the full
|
||||
`<ShareDialog />` would be overkill. The component is purely
|
||||
presentational — recipient data flows in via `props.recipients` and
|
||||
mutations exit via the `onRevoke(grantId)` callback. There is no
|
||||
internal state, no engine dependency, and no async lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders one `<li>` per recipient with `data-grant-id` and
|
||||
`data-revoked` attributes for external styling and scripting.
|
||||
- Permission label rendered from the `primaryPermission` projection
|
||||
computed by `useSharing` (no recompute here).
|
||||
- Optional `onRevoke` — when omitted, the revoke button is hidden and
|
||||
the component is strictly read-only.
|
||||
- Keyboard accessibility: revoke button is a native `<button>`, plus
|
||||
Delete / Backspace shortcuts trigger revoke when the button is
|
||||
focused.
|
||||
- Empty state: when `recipients` is empty, renders a polite
|
||||
`aria-live="polite"` status message rather than an empty list.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { FileAccessList } from "@suaveplan/drive-react-sharing";
|
||||
|
||||
<FileAccessList recipients={sharing.recipients} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
function Panel() {
|
||||
const sharing = useSharing(/* ... */);
|
||||
return (
|
||||
<FileAccessList
|
||||
recipients={sharing.recipients}
|
||||
onRevoke={(id) => sharing.removeRecipient(id)}
|
||||
revokeLabel="Remove"
|
||||
emptyLabel="No collaborators yet"
|
||||
listLabel="Document collaborators"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FileAccessListProps`:
|
||||
- `recipients: readonly ShareRecipient[]` — required.
|
||||
- `onRevoke?: (grantId: string) => void` — optional revoke callback.
|
||||
- `revokeLabel?: string` — defaults to `"Revoke"`.
|
||||
- `emptyLabel?: string` — defaults to `"No recipients"`.
|
||||
- `listLabel?: string` — defaults to `"Recipients with access"`.
|
||||
- `className?: string`, `style?: CSSProperties` — root overrides.
|
||||
- `FileAccessList(props): ReactElement` — pure functional component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component intentionally renders a `<button>` rather than a
|
||||
right-click context menu so revoke is reachable via keyboard alone.
|
||||
The Delete / Backspace shortcuts fire `onRevoke` directly without
|
||||
requiring a confirmation step — consumers that need a confirmation
|
||||
flow should compose their own modal in front of the callback.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { userId } from "@suaveplan/drive-sharing";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ShareRecipient } from "../../types/sharing.types.js";
|
||||
import { FileAccessList } from "./file-access-list.js";
|
||||
|
||||
export default {
|
||||
title: "drive-react-sharing/FileAccessList",
|
||||
component: FileAccessList,
|
||||
};
|
||||
|
||||
const SAMPLE: readonly ShareRecipient[] = [
|
||||
{
|
||||
grantId: "g-1",
|
||||
recipientId: userId("alice@example.com"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
grantId: "g-2",
|
||||
recipientId: userId("bob@example.com"),
|
||||
permissions: ["write"],
|
||||
primaryPermission: "write",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
grantId: "g-3",
|
||||
recipientId: userId("carol@example.com"),
|
||||
permissions: ["admin"],
|
||||
primaryPermission: "admin",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = (): ReactElement => (
|
||||
<FileAccessList recipients={SAMPLE} />
|
||||
);
|
||||
|
||||
export const Empty = (): ReactElement => <FileAccessList recipients={[]} />;
|
||||
|
||||
export const Playground = (): ReactElement => (
|
||||
<FileAccessList
|
||||
recipients={SAMPLE}
|
||||
onRevoke={(id) => console.log("revoke", id)}
|
||||
revokeLabel="Remove"
|
||||
listLabel="Document collaborators"
|
||||
/>
|
||||
);
|
||||
|
||||
export const ReadOnly = (): ReactElement => (
|
||||
<FileAccessList recipients={SAMPLE} />
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
import { userId } from "@suaveplan/drive-sharing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { screen, within } from "@suaveplan/testing-dom/queries";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import type { ShareRecipient } from "../../types/sharing.types.js";
|
||||
import { FileAccessList } from "./file-access-list.js";
|
||||
|
||||
function makeRecipients(): readonly ShareRecipient[] {
|
||||
return [
|
||||
{
|
||||
grantId: "g-1",
|
||||
recipientId: userId("alice"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
grantId: "g-2",
|
||||
recipientId: userId("bob"),
|
||||
permissions: ["admin"],
|
||||
primaryPermission: "admin",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("<FileAccessList />", () => {
|
||||
it("renders an empty state when recipients is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<FileAccessList recipients={[]} />);
|
||||
const empty = screen.getByRole("status");
|
||||
expect(empty.textContent).toContain("No recipients");
|
||||
expect(empty.getAttribute("data-empty")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders one row per recipient with data-grant-id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<FileAccessList recipients={makeRecipients()} />);
|
||||
const list = screen.getByRole("list");
|
||||
const items = within(list).getAllByRole("listitem");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0]?.getAttribute("data-grant-id")).toBe("g-1");
|
||||
expect(items[1]?.getAttribute("data-grant-id")).toBe("g-2");
|
||||
});
|
||||
|
||||
it("hides revoke button when onRevoke is not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<FileAccessList recipients={makeRecipients()} />);
|
||||
expect(screen.queryAllByRole("button").length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders revoke button when onRevoke is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const { user } = renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
onRevoke={(id) => calls.push(id)}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
if (buttons[0] !== undefined) await user.click(buttons[0]);
|
||||
expect(calls).toEqual(["g-1"]);
|
||||
});
|
||||
|
||||
it("Delete key on revoke button fires onRevoke", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const { user } = renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
onRevoke={(id) => calls.push(id)}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const first = buttons[0];
|
||||
if (first !== undefined) {
|
||||
first.focus();
|
||||
await user.keyboard("{Delete}");
|
||||
}
|
||||
expect(calls).toEqual(["g-1"]);
|
||||
});
|
||||
|
||||
it("Backspace key on revoke button fires onRevoke", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const { user } = renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
onRevoke={(id) => calls.push(id)}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const first = buttons[0];
|
||||
if (first !== undefined) {
|
||||
first.focus();
|
||||
await user.keyboard("{Backspace}");
|
||||
}
|
||||
expect(calls).toEqual(["g-1"]);
|
||||
});
|
||||
|
||||
it("ignores non-revoke keys", { timeout: 5000 }, async () => {
|
||||
const calls: string[] = [];
|
||||
const { user } = renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
onRevoke={(id) => calls.push(id)}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const first = buttons[0];
|
||||
if (first !== undefined) {
|
||||
first.focus();
|
||||
await user.keyboard("a");
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders custom labels", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
onRevoke={() => {}}
|
||||
revokeLabel="Remove"
|
||||
listLabel="Collaborators"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("list").getAttribute("aria-label")).toBe(
|
||||
"Collaborators"
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons[0]?.textContent).toBe("Remove");
|
||||
expect(buttons[0]?.getAttribute("aria-label")).toContain("Remove");
|
||||
});
|
||||
|
||||
it("uses custom emptyLabel when supplied", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<FileAccessList recipients={[]} emptyLabel="No collaborators yet" />
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"No collaborators yet"
|
||||
);
|
||||
});
|
||||
|
||||
it("marks revoked recipients via data-revoked", { timeout: 5000 }, () => {
|
||||
const recipients: readonly ShareRecipient[] = [
|
||||
{
|
||||
grantId: "g-9",
|
||||
recipientId: userId("rev"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: true,
|
||||
},
|
||||
];
|
||||
renderComponent(<FileAccessList recipients={recipients} />);
|
||||
const item = screen.getByRole("listitem");
|
||||
expect(item.getAttribute("data-revoked")).toBe("true");
|
||||
});
|
||||
|
||||
it("applies className and style overrides", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<FileAccessList
|
||||
recipients={makeRecipients()}
|
||||
className="my-list"
|
||||
style={{ borderTop: "1px solid red" }}
|
||||
/>
|
||||
);
|
||||
const list = screen.getByRole("list");
|
||||
expect(list.classList.contains("my-list")).toBe(true);
|
||||
expect(list.getAttribute("style")).toContain("border-top");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { CSSProperties, KeyboardEvent, ReactElement } from "react";
|
||||
import type {
|
||||
SharePermission,
|
||||
ShareRecipient,
|
||||
} from "../../types/sharing.types.js";
|
||||
|
||||
/** Props accepted by {@link FileAccessList}. */
|
||||
export interface FileAccessListProps {
|
||||
/** Recipients to render. */
|
||||
readonly recipients: readonly ShareRecipient[];
|
||||
/** Optional revoke handler; when omitted the revoke button is hidden. */
|
||||
readonly onRevoke?: (grantId: string) => void;
|
||||
/** Localised string for the revoke button (defaults to "Revoke"). */
|
||||
readonly revokeLabel?: string;
|
||||
/** Localised empty-state message. */
|
||||
readonly emptyLabel?: string;
|
||||
/** Accessible label for the underlying list. */
|
||||
readonly listLabel?: string;
|
||||
/** ClassName applied to root `<ul>`. */
|
||||
readonly className?: string;
|
||||
/** Style overrides for root `<ul>`. */
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
const PERMISSION_LABELS: Record<SharePermission, string> = {
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
delete: "Delete",
|
||||
share: "Share",
|
||||
admin: "Admin",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only list of share recipients with an optional per-row revoke
|
||||
* button. Rendered as a `<ul role="list">` of recipient `<li role="listitem">`
|
||||
* entries; each row carries a `data-grant-id` attribute so external
|
||||
* scripts can target rows without depending on DOM order. The revoke
|
||||
* button is keyboard-activatable (Enter/Space via native `<button>`
|
||||
* semantics) and only rendered when `onRevoke` is provided.
|
||||
*/
|
||||
export function FileAccessList(props: FileAccessListProps): ReactElement {
|
||||
const revokeLabel = props.revokeLabel ?? "Revoke";
|
||||
const emptyLabel = props.emptyLabel ?? "No recipients";
|
||||
const listLabel = props.listLabel ?? "Recipients with access";
|
||||
|
||||
if (props.recipients.length === 0) {
|
||||
return (
|
||||
<p data-empty="true" role="status" aria-live="polite">
|
||||
{emptyLabel}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
grantId: string
|
||||
): void {
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
props.onRevoke?.(grantId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
aria-label={listLabel}
|
||||
className={props.className}
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
{props.recipients.map((r) => (
|
||||
<li
|
||||
key={r.grantId}
|
||||
data-grant-id={r.grantId}
|
||||
data-revoked={r.revoked ? "true" : "false"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 8px",
|
||||
borderBottom: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{r.recipientId}</span>
|
||||
<span
|
||||
data-permission={r.primaryPermission}
|
||||
style={{ fontSize: 12, opacity: 0.75 }}
|
||||
>
|
||||
{PERMISSION_LABELS[r.primaryPermission]}
|
||||
</span>
|
||||
{props.onRevoke !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${revokeLabel} ${r.recipientId}`}
|
||||
onClick={() => props.onRevoke?.(r.grantId)}
|
||||
onKeyDown={(e) => handleKeyDown(e, r.grantId)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 4,
|
||||
border: "1px solid currentColor",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{revokeLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
FileAccessList.displayName = "SuaveplanFileAccessList";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
FileAccessList,
|
||||
type FileAccessListProps,
|
||||
} from "./file-access-list.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PartitionBadge,
|
||||
type PartitionBadgeProps,
|
||||
} from "./partition-badge.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# `<PartitionBadge />`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<PartitionBadge />` renders a compact label visualising a partition's
|
||||
classification — `personal`, `shared`, `public`, or `encrypted` — plus
|
||||
an optional padlock indicator when the partition is encrypted. It is a
|
||||
pure presentational component with no engine dependencies, suitable for
|
||||
list rows, sidebar headers, and breadcrumbs.
|
||||
|
||||
## Features
|
||||
|
||||
- Four partition classifications matching the engine's `PartitionType`
|
||||
union.
|
||||
- Automatic padlock SVG when `type === "encrypted"` or `encrypted` prop
|
||||
is explicit-true.
|
||||
- Inline-styled by default so the component drops into any tree without
|
||||
a stylesheet. `className` / `style` props override.
|
||||
- Accessibility: `role="status"`, `aria-label` mentioning the type and
|
||||
encryption state, decorative SVG marked `aria-hidden="true"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { PartitionBadge } from "@suaveplan/drive-react-sharing";
|
||||
|
||||
<PartitionBadge type="shared" />;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
<PartitionBadge
|
||||
type="encrypted"
|
||||
label="E2E"
|
||||
className="my-badge"
|
||||
style={{ marginInlineStart: 12 }}
|
||||
/>
|
||||
```
|
||||
|
||||
`encrypted` forces the lock icon even when the type is something other
|
||||
than `encrypted` — useful for representing a shared partition whose
|
||||
policy mandates encryption.
|
||||
|
||||
```tsx
|
||||
<PartitionBadge type="shared" encrypted />
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PartitionBadgeProps`:
|
||||
- `type: PartitionType` — required, drives label + colour.
|
||||
- `encrypted?: boolean` — optional, forces lock icon.
|
||||
- `label?: string` — optional, overrides the default text.
|
||||
- `className?: string` — applied to root `<span>`.
|
||||
- `style?: CSSProperties` — merged onto default style.
|
||||
- `PartitionBadge(props): ReactElement` — pure functional component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Colours are hard-coded HEX values with a 10% alpha background, chosen
|
||||
to ensure 4.5:1 WCAG contrast against the colour foreground. Consumers
|
||||
that require theme-aware colouring should pass an explicit `style`
|
||||
override rather than monkey-patching the defaults. The `data-partition-type`
|
||||
attribute is set so external stylesheets can target each variant
|
||||
without depending on the inline `style` prop.
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { PartitionBadge } from "./partition-badge.js";
|
||||
|
||||
export default {
|
||||
title: "drive-react-sharing/PartitionBadge",
|
||||
component: PartitionBadge,
|
||||
};
|
||||
|
||||
export const Default = (): ReactElement => <PartitionBadge type="shared" />;
|
||||
|
||||
export const Playground = (): ReactElement => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<PartitionBadge type="personal" />
|
||||
<PartitionBadge type="shared" />
|
||||
<PartitionBadge type="public" />
|
||||
<PartitionBadge type="encrypted" />
|
||||
<PartitionBadge type="shared" encrypted />
|
||||
<PartitionBadge type="personal" label="Custom Label" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Personal = (): ReactElement => <PartitionBadge type="personal" />;
|
||||
export const Shared = (): ReactElement => <PartitionBadge type="shared" />;
|
||||
export const Public = (): ReactElement => <PartitionBadge type="public" />;
|
||||
export const Encrypted = (): ReactElement => (
|
||||
<PartitionBadge type="encrypted" />
|
||||
);
|
||||
export const SharedEncrypted = (): ReactElement => (
|
||||
<PartitionBadge type="shared" encrypted />
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { screen } from "@suaveplan/testing-dom/queries";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { PartitionBadge } from "./partition-badge.js";
|
||||
|
||||
describe("<PartitionBadge />", () => {
|
||||
it("renders default label for each partition type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const t of [
|
||||
"personal",
|
||||
"shared",
|
||||
"public",
|
||||
"encrypted",
|
||||
] as const) {
|
||||
const { unmount } = renderComponent(<PartitionBadge type={t} />);
|
||||
const label =
|
||||
t === "personal"
|
||||
? "Personal"
|
||||
: t === "shared"
|
||||
? "Shared"
|
||||
: t === "public"
|
||||
? "Public"
|
||||
: "Encrypted";
|
||||
const node = screen.getByRole("status");
|
||||
expect(node.textContent).toContain(label);
|
||||
expect(node.getAttribute("data-partition-type")).toBe(t);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders lock icon when type is encrypted", { timeout: 5000 }, () => {
|
||||
renderComponent(<PartitionBadge type="encrypted" />);
|
||||
const badge = screen.getByRole("status");
|
||||
const svg = badge.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(badge.getAttribute("aria-label")).toContain("encrypted");
|
||||
});
|
||||
|
||||
it("renders lock icon when encrypted prop is true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<PartitionBadge type="shared" encrypted />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.querySelector("svg")).not.toBeNull();
|
||||
expect(badge.getAttribute("aria-label")).toContain("encrypted");
|
||||
});
|
||||
|
||||
it("does not render lock when encrypted is false on non-encrypted type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<PartitionBadge type="public" encrypted={false} />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.querySelector("svg")).toBeNull();
|
||||
expect(badge.getAttribute("aria-label")).not.toContain("encrypted");
|
||||
});
|
||||
|
||||
it("honours custom label override", { timeout: 5000 }, () => {
|
||||
renderComponent(<PartitionBadge type="shared" label="Team" />);
|
||||
expect(screen.getByRole("status").textContent).toContain("Team");
|
||||
});
|
||||
|
||||
it("applies className and style overrides", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<PartitionBadge
|
||||
type="personal"
|
||||
className="my-badge"
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.classList.contains("my-badge")).toBe(true);
|
||||
expect(badge.getAttribute("style")).toContain("margin-left");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import type { PartitionBadgeType } from "../../types/sharing.types.js";
|
||||
|
||||
/** Props accepted by {@link PartitionBadge}. */
|
||||
export interface PartitionBadgeProps {
|
||||
/** Partition classification driving label + colour. */
|
||||
readonly type: PartitionBadgeType;
|
||||
/** Whether the partition is encrypted (renders the lock icon). */
|
||||
readonly encrypted?: boolean;
|
||||
/** Optional override label (defaults to a capitalised `type`). */
|
||||
readonly label?: string;
|
||||
/** Class name applied to the root `<span>`. */
|
||||
readonly className?: string;
|
||||
/** Style overrides merged onto the default style. */
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
const LABELS: Record<PartitionBadgeType, string> = {
|
||||
personal: "Personal",
|
||||
shared: "Shared",
|
||||
public: "Public",
|
||||
encrypted: "Encrypted",
|
||||
};
|
||||
|
||||
const COLORS: Record<PartitionBadgeType, string> = {
|
||||
personal: "#6b7280",
|
||||
shared: "#2563eb",
|
||||
public: "#059669",
|
||||
encrypted: "#7c3aed",
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact, presentational badge component visualising a partition's
|
||||
* classification + encryption state. Renders an inline-block `<span>`
|
||||
* with `role="status"` so assistive tech announces the badge's text
|
||||
* when it appears in a live region. The encryption indicator is a
|
||||
* decorative inline SVG with `aria-hidden="true"` because the
|
||||
* `Encrypted` suffix in the visible label conveys the same meaning.
|
||||
*/
|
||||
export function PartitionBadge(props: PartitionBadgeProps): ReactElement {
|
||||
const text = props.label ?? LABELS[props.type];
|
||||
const color = COLORS[props.type];
|
||||
const showLock = props.encrypted === true || props.type === "encrypted";
|
||||
const baseStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: `${color}1a`,
|
||||
color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={`Partition type ${text}${showLock ? ", encrypted" : ""}`}
|
||||
data-partition-type={props.type}
|
||||
className={props.className}
|
||||
style={{ ...baseStyle, ...props.style }}
|
||||
>
|
||||
{showLock ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 1a3 3 0 0 0-3 3v3H4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-1V4a3 3 0 0 0-3-3Zm-2 6V4a2 2 0 1 1 4 0v3H6Z" />
|
||||
</svg>
|
||||
) : null}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartitionBadge.displayName = "SuaveplanPartitionBadge";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ShareDialog,
|
||||
type ShareDialogLabels,
|
||||
type ShareDialogProps,
|
||||
} from "./share-dialog.js";
|
||||
@@ -0,0 +1,108 @@
|
||||
# `<ShareDialog />`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<ShareDialog />` renders the modal sharing UI for a partition. It
|
||||
exposes a recipients list, an add-recipient form (text input plus
|
||||
permission select), and a revoke button per row. The component is
|
||||
controlled — `open`, `recipients`, `loading`, and `errorMessage` are
|
||||
all owned by the parent. Wire it to `useSharing` for live engine
|
||||
integration or hand it static props in Storybook.
|
||||
|
||||
## Features
|
||||
|
||||
- Controlled `open` prop — renders `null` when closed so callers can
|
||||
conditionally mount.
|
||||
- Focus moves to the recipient input on open.
|
||||
- ESC key closes the dialog. Backdrop click also closes; clicks
|
||||
inside the dialog do NOT close (stopPropagation).
|
||||
- ARIA: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` tied to
|
||||
the title heading. Error region uses `role="alert"`.
|
||||
- Recipient list reuses the same `data-grant-id` row contract as
|
||||
`<FileAccessList />`.
|
||||
- Localisable: pass `labels` to override every string. Permission
|
||||
options localise individually.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ShareDialog } from "@suaveplan/drive-react-sharing";
|
||||
import { useState } from "react";
|
||||
|
||||
function Trigger() {
|
||||
const sharing = useSharing(/* ... */);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>Share</button>
|
||||
<ShareDialog
|
||||
open={open}
|
||||
recipients={sharing.recipients}
|
||||
onAdd={(id, p) => sharing.addRecipient(id, p)}
|
||||
onRemove={(id) => sharing.removeRecipient(id)}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Custom labels for a localised application:
|
||||
|
||||
```tsx
|
||||
<ShareDialog
|
||||
open={open}
|
||||
recipients={sharing.recipients}
|
||||
loading={sharing.loading}
|
||||
errorMessage={sharing.error?.message}
|
||||
defaultPermission="write"
|
||||
onAdd={(id, p) => sharing.addRecipient(id, p)}
|
||||
onRemove={(id) => sharing.removeRecipient(id)}
|
||||
onClose={() => setOpen(false)}
|
||||
labels={{
|
||||
title: "Partager",
|
||||
add: "Ajouter",
|
||||
revoke: "Révoquer",
|
||||
empty: "Aucun destinataire",
|
||||
permissionOptions: {
|
||||
read: "Lecture",
|
||||
write: "Écriture",
|
||||
delete: "Suppression",
|
||||
share: "Partage",
|
||||
admin: "Administrateur",
|
||||
},
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ShareDialogProps`:
|
||||
- `open: boolean` — required.
|
||||
- `recipients: readonly ShareRecipient[]` — required.
|
||||
- `onAdd(recipientId, permission): void | Promise<void>` — required.
|
||||
- `onRemove(grantId): void | Promise<void>` — required.
|
||||
- `onClose(): void` — required.
|
||||
- `title?: string` — overrides `labels.title`.
|
||||
- `loading?: boolean` — disables interactive controls.
|
||||
- `errorMessage?: string` — renders `role="alert"` banner.
|
||||
- `defaultPermission?: SharePermission` — initial select value.
|
||||
- `labels?: Partial<ShareDialogLabels>` — string overrides.
|
||||
- `ShareDialogLabels` — exported interface; permission options
|
||||
override individually.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dialog is intentionally a controlled component because the open
|
||||
state is usually tied to other UI in the parent (an avatar stack, a
|
||||
"Share" button, a routing flag). Storybook can drive the entire surface
|
||||
with mocked callbacks. The error region is rendered only when
|
||||
`errorMessage` is a non-empty string — passing an undefined or
|
||||
zero-length string hides it. The submit button is disabled when the
|
||||
recipient draft is empty so users cannot accidentally fire `onAdd`
|
||||
with whitespace.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { userId } from "@suaveplan/drive-sharing";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { ShareRecipient } from "../../types/sharing.types.js";
|
||||
import { ShareDialog } from "./share-dialog.js";
|
||||
|
||||
export default {
|
||||
title: "drive-react-sharing/ShareDialog",
|
||||
component: ShareDialog,
|
||||
};
|
||||
|
||||
const SAMPLE: readonly ShareRecipient[] = [
|
||||
{
|
||||
grantId: "g-1",
|
||||
recipientId: userId("alice@example.com"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
grantId: "g-2",
|
||||
recipientId: userId("bob@example.com"),
|
||||
permissions: ["admin"],
|
||||
primaryPermission: "admin",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = (): ReactElement => (
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={SAMPLE}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EmptyList = (): ReactElement => (
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Loading = (): ReactElement => (
|
||||
<ShareDialog
|
||||
open={true}
|
||||
loading={true}
|
||||
recipients={SAMPLE}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithError = (): ReactElement => (
|
||||
<ShareDialog
|
||||
open={true}
|
||||
errorMessage="Recipient not found"
|
||||
recipients={SAMPLE}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Playground = (): ReactElement => {
|
||||
const [recipients, setRecipients] =
|
||||
useState<readonly ShareRecipient[]>(SAMPLE);
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open
|
||||
</button>
|
||||
<ShareDialog
|
||||
open={open}
|
||||
recipients={recipients}
|
||||
onAdd={(id, perm) =>
|
||||
setRecipients((prev) => [
|
||||
...prev,
|
||||
{
|
||||
grantId: `g-${Date.now()}`,
|
||||
recipientId: userId(id),
|
||||
permissions: [perm],
|
||||
primaryPermission: perm,
|
||||
createdAt: Date.now(),
|
||||
revoked: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
onRemove={(id) =>
|
||||
setRecipients((prev) =>
|
||||
prev.filter((r) => r.grantId !== id)
|
||||
)
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
import { userId } from "@suaveplan/drive-sharing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { screen } from "@suaveplan/testing-dom/queries";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import type {
|
||||
SharePermission,
|
||||
ShareRecipient,
|
||||
} from "../../types/sharing.types.js";
|
||||
import { ShareDialog } from "./share-dialog.js";
|
||||
|
||||
function recipients(): readonly ShareRecipient[] {
|
||||
return [
|
||||
{
|
||||
grantId: "g-1",
|
||||
recipientId: userId("alice"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("<ShareDialog />", () => {
|
||||
it("renders null when closed", { timeout: 5000 }, () => {
|
||||
const view = renderComponent(
|
||||
<ShareDialog
|
||||
open={false}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("renders dialog with proper ARIA when open", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={recipients()}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
const labelId = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelId).not.toBeNull();
|
||||
const heading = document.getElementById(labelId as string);
|
||||
expect(heading?.textContent).toBe("Share");
|
||||
});
|
||||
|
||||
it("focuses recipient input on open", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("Add recipient (email or user id)");
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("ESC key closes the dialog", { timeout: 5000 }, async () => {
|
||||
let closed = 0;
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {
|
||||
closed += 1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(closed).toBe(1);
|
||||
});
|
||||
|
||||
it("backdrop click closes the dialog", { timeout: 5000 }, async () => {
|
||||
let closed = 0;
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {
|
||||
closed += 1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const backdrop = screen.getByTestId("share-dialog-backdrop");
|
||||
await user.click(backdrop);
|
||||
expect(closed).toBe(1);
|
||||
});
|
||||
|
||||
it("click inside dialog does not close", { timeout: 5000 }, async () => {
|
||||
let closed = 0;
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {
|
||||
closed += 1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(dialog);
|
||||
expect(closed).toBe(0);
|
||||
});
|
||||
|
||||
it("X button closes", { timeout: 5000 }, async () => {
|
||||
let closed = 0;
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {
|
||||
closed += 1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(closed).toBe(1);
|
||||
});
|
||||
|
||||
it("submits add form with trimmed value and clears input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: Array<[string, SharePermission]> = [];
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={(id, p) => {
|
||||
calls.push([id, p]);
|
||||
}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
"Add recipient (email or user id)"
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, " bob ");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(calls).toEqual([["bob", "read"]]);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("submit button is disabled when input is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Add" });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("form submit with whitespace-only input is a no-op", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let called = 0;
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {
|
||||
called += 1;
|
||||
}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
"Add recipient (email or user id)"
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
const form = input.closest("form") as HTMLFormElement;
|
||||
fireEvent.submit(form);
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
|
||||
it("permission select updates submitted permission", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: Array<[string, SharePermission]> = [];
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={(id, p) => {
|
||||
calls.push([id, p]);
|
||||
}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText("Add recipient (email or user id)"),
|
||||
"alice"
|
||||
);
|
||||
await user.selectOptions(screen.getByLabelText("Permission"), "admin");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(calls).toEqual([["alice", "admin"]]);
|
||||
});
|
||||
|
||||
it("uses defaultPermission for the select", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
defaultPermission="admin"
|
||||
/>
|
||||
);
|
||||
const sel = screen.getByLabelText("Permission") as HTMLSelectElement;
|
||||
expect(sel.value).toBe("admin");
|
||||
});
|
||||
|
||||
it("renders empty state when recipients is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("No recipients yet")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders recipients with revoke buttons", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const removed: string[] = [];
|
||||
const { user } = renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={recipients()}
|
||||
onAdd={() => {}}
|
||||
onRemove={(id) => removed.push(id)}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const revoke = screen.getByRole("button", { name: /Revoke alice/ });
|
||||
await user.click(revoke);
|
||||
expect(removed).toEqual(["g-1"]);
|
||||
});
|
||||
|
||||
it("loading disables form controls and revoke buttons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
loading={true}
|
||||
recipients={recipients()}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
"Add recipient (email or user id)"
|
||||
) as HTMLInputElement;
|
||||
const sel = screen.getByLabelText("Permission") as HTMLSelectElement;
|
||||
const revoke = screen.getByRole("button", {
|
||||
name: /Revoke alice/,
|
||||
}) as HTMLButtonElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(sel.disabled).toBe(true);
|
||||
expect(revoke.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders error message via role=alert", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
errorMessage="Nope"
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toBe("Nope");
|
||||
});
|
||||
|
||||
it("hides error region when errorMessage is empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
errorMessage=""
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("custom title prop overrides label", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
title="Custom Title"
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("heading").textContent).toBe("Custom Title");
|
||||
});
|
||||
|
||||
it("custom labels override defaults including permissionOptions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={recipients()}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
labels={{
|
||||
title: "Partager",
|
||||
add: "Ajouter",
|
||||
permissionOptions: {
|
||||
read: "Lecture",
|
||||
write: "Écriture",
|
||||
delete: "Supprimer",
|
||||
share: "Partage",
|
||||
admin: "Admin",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("heading").textContent).toBe("Partager");
|
||||
expect(screen.getByRole("button", { name: "Ajouter" })).toBeDefined();
|
||||
// Permission option label propagates to the row's primaryPermission render.
|
||||
const sel = screen.getByLabelText("Permission") as HTMLSelectElement;
|
||||
expect(sel.querySelector("option")?.textContent).toBe("Lecture");
|
||||
});
|
||||
|
||||
it("partial labels override merges permissionOptions individually", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<ShareDialog
|
||||
open={true}
|
||||
recipients={[]}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={() => {}}
|
||||
labels={{ add: "Go" }}
|
||||
/>
|
||||
);
|
||||
// permissionOptions falls through to defaults
|
||||
const sel = screen.getByLabelText("Permission") as HTMLSelectElement;
|
||||
expect(sel.querySelector("option")?.textContent).toBe("Can view");
|
||||
expect(screen.getByRole("button", { name: "Go" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
SharePermission,
|
||||
ShareRecipient,
|
||||
} from "../../types/sharing.types.js";
|
||||
|
||||
/** Props accepted by {@link ShareDialog}. */
|
||||
export interface ShareDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly recipients: readonly ShareRecipient[];
|
||||
readonly onAdd: (
|
||||
recipientId: string,
|
||||
permission: SharePermission
|
||||
) => void | Promise<void>;
|
||||
readonly onRemove: (grantId: string) => void | Promise<void>;
|
||||
readonly onClose: () => void;
|
||||
readonly title?: string;
|
||||
readonly loading?: boolean;
|
||||
readonly errorMessage?: string;
|
||||
readonly defaultPermission?: SharePermission;
|
||||
readonly labels?: Partial<ShareDialogLabels>;
|
||||
}
|
||||
|
||||
/** Localised string bundle accepted by {@link ShareDialog}. */
|
||||
export interface ShareDialogLabels {
|
||||
readonly title: string;
|
||||
readonly close: string;
|
||||
readonly recipientPlaceholder: string;
|
||||
readonly permission: string;
|
||||
readonly add: string;
|
||||
readonly revoke: string;
|
||||
readonly empty: string;
|
||||
readonly permissionOptions: Record<SharePermission, string>;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: ShareDialogLabels = {
|
||||
title: "Share",
|
||||
close: "Close",
|
||||
recipientPlaceholder: "Add recipient (email or user id)",
|
||||
permission: "Permission",
|
||||
add: "Add",
|
||||
revoke: "Revoke",
|
||||
empty: "No recipients yet",
|
||||
permissionOptions: {
|
||||
read: "Can view",
|
||||
write: "Can edit",
|
||||
delete: "Can delete",
|
||||
share: "Can share",
|
||||
admin: "Admin",
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_STYLE: CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const BACKDROP_BUTTON_STYLE: CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const DIALOG_STYLE: CSSProperties = {
|
||||
position: "relative",
|
||||
background: "white",
|
||||
color: "#111827",
|
||||
borderRadius: 8,
|
||||
width: "min(480px, 92vw)",
|
||||
maxHeight: "80vh",
|
||||
overflow: "auto",
|
||||
padding: 16,
|
||||
boxShadow: "0 10px 40px rgba(0,0,0,0.25)",
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const ROW_STYLE: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid rgba(0,0,0,0.06)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal "Share" dialog covering recipient enumeration, add-form, and
|
||||
* per-row revoke. Renders nothing when `open === false`. The dialog
|
||||
* uses an explicit `role="dialog"` shell with `aria-modal="true"` and
|
||||
* `aria-labelledby` tied to the title heading. Focus moves to the
|
||||
* recipient input on open; ESC and backdrop click both close. The
|
||||
* backdrop is a real `<button>` so click is keyboard-equivalent.
|
||||
*/
|
||||
export function ShareDialog(props: ShareDialogProps): ReactElement | null {
|
||||
const labels = mergeLabels(DEFAULT_LABELS, props.labels);
|
||||
const titleId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [recipientDraft, setRecipientDraft] = useState("");
|
||||
const [permissionDraft, setPermissionDraft] = useState<SharePermission>(
|
||||
props.defaultPermission ?? "read"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
const handleDialogKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
props.onClose();
|
||||
}
|
||||
},
|
||||
[props]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
const trimmed = recipientDraft.trim();
|
||||
if (trimmed === "") return;
|
||||
await props.onAdd(trimmed, permissionDraft);
|
||||
setRecipientDraft("");
|
||||
},
|
||||
[permissionDraft, props, recipientDraft]
|
||||
);
|
||||
|
||||
if (!props.open) return null;
|
||||
|
||||
return (
|
||||
<div style={ROOT_STYLE}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${labels.close} (backdrop)`}
|
||||
onClick={props.onClose}
|
||||
data-testid="share-dialog-backdrop"
|
||||
style={BACKDROP_BUTTON_STYLE}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
style={DIALOG_STYLE}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<h2 id={titleId} style={{ margin: 0, fontSize: 18 }}>
|
||||
{props.title ?? labels.title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.close}
|
||||
onClick={props.onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{props.errorMessage !== undefined &&
|
||||
props.errorMessage.length > 0 ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
background: "#fef2f2",
|
||||
color: "#991b1b",
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
{props.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", gap: 8, marginBottom: 12 }}
|
||||
aria-label={labels.add}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={recipientDraft}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setRecipientDraft(e.target.value)
|
||||
}
|
||||
placeholder={labels.recipientPlaceholder}
|
||||
aria-label={labels.recipientPlaceholder}
|
||||
disabled={props.loading === true}
|
||||
style={{ flex: 1, padding: 8 }}
|
||||
/>
|
||||
<select
|
||||
value={permissionDraft}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
setPermissionDraft(
|
||||
e.target.value as SharePermission
|
||||
)
|
||||
}
|
||||
disabled={props.loading === true}
|
||||
aria-label={labels.permission}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
{(
|
||||
Object.keys(
|
||||
labels.permissionOptions
|
||||
) as SharePermission[]
|
||||
).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{labels.permissionOptions[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
props.loading === true ||
|
||||
recipientDraft.trim() === ""
|
||||
}
|
||||
style={{ padding: "8px 12px" }}
|
||||
>
|
||||
{labels.add}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{props.recipients.length === 0 ? (
|
||||
<p data-empty="true" style={{ opacity: 0.7 }}>
|
||||
{labels.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={labels.title}
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{props.recipients.map((r) => (
|
||||
<li
|
||||
key={r.grantId}
|
||||
data-grant-id={r.grantId}
|
||||
style={ROW_STYLE}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{r.recipientId}</span>
|
||||
<span
|
||||
data-permission={r.primaryPermission}
|
||||
style={{ fontSize: 12, opacity: 0.75 }}
|
||||
>
|
||||
{
|
||||
labels.permissionOptions[
|
||||
r.primaryPermission
|
||||
]
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${labels.revoke} ${r.recipientId}`}
|
||||
disabled={props.loading === true}
|
||||
onClick={() => props.onRemove(r.grantId)}
|
||||
style={{ padding: "4px 8px" }}
|
||||
>
|
||||
{labels.revoke}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ShareDialog.displayName = "SuaveplanShareDialog";
|
||||
|
||||
function mergeLabels(
|
||||
base: ShareDialogLabels,
|
||||
overrides: Partial<ShareDialogLabels> | undefined
|
||||
): ShareDialogLabels {
|
||||
if (overrides === undefined) return base;
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
permissionOptions: {
|
||||
...base.permissionOptions,
|
||||
...(overrides.permissionOptions ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# `drive-react-sharing` error codes
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/drive-react-sharing` raises every failure as a
|
||||
`DriveReactSharingError` — a `SuaveplanError` subclass with a stable code
|
||||
registry. Codes are namespaced under `DRIVE_REACT_SHARING.*` so consumers
|
||||
can write exhaustive `switch (err.code) { ... }` matches at the call site
|
||||
without depending on the underlying `@suaveplan/drive-sharing` engine
|
||||
codes. The actual engine error (or any other thrown value) is preserved
|
||||
on `cause` so a chain walker can still surface low-level diagnostics.
|
||||
|
||||
## Features
|
||||
|
||||
- Module-load-time registration with the global Suaveplan code registry.
|
||||
- Brand symbol on every instance — defence-in-depth against cross-realm
|
||||
`instanceof` failures via `DriveReactSharingError.is(value)`.
|
||||
- Seven codes covering input validation (`INVALID_RECIPIENT`), engine
|
||||
authorization failures (`PERMISSION_DENIED`, `GRANT_NOT_FOUND`), and
|
||||
hook action wrapping (`ADD_FAILED`, `REVOKE_FAILED`, `UPDATE_FAILED`,
|
||||
`REFRESH_FAILED`).
|
||||
- Compatible with the `SuaveplanError.cause` chain so the original
|
||||
`DriveShareError` thrown by the engine is recoverable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DriveReactSharingError,
|
||||
DRIVE_REACT_SHARING_CODES,
|
||||
} from "@suaveplan/drive-react-sharing";
|
||||
|
||||
try {
|
||||
await result.addRecipient("", "read");
|
||||
} catch (err) {
|
||||
if (DriveReactSharingError.is(err)) {
|
||||
if (err.code === DRIVE_REACT_SHARING_CODES.INVALID_RECIPIENT) {
|
||||
// Show inline form validation error
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wrapping an engine-level error and walking the cause chain:
|
||||
|
||||
```ts
|
||||
import {
|
||||
DriveReactSharingError,
|
||||
DRIVE_REACT_SHARING_CODES,
|
||||
} from "@suaveplan/drive-react-sharing";
|
||||
import { DriveShareError } from "@suaveplan/drive-sharing";
|
||||
|
||||
try {
|
||||
await result.addRecipient("user-42", "admin");
|
||||
} catch (err) {
|
||||
if (
|
||||
DriveReactSharingError.is(err) &&
|
||||
err.code === DRIVE_REACT_SHARING_CODES.ADD_FAILED &&
|
||||
err.cause instanceof DriveShareError
|
||||
) {
|
||||
console.error("engine code:", err.cause.code);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DRIVE_REACT_SHARING_CODES` — frozen record of every code literal.
|
||||
- `DriveReactSharingErrorCode` — union type of every code value.
|
||||
- `class DriveReactSharingError extends SuaveplanError<DriveReactSharingErrorCode>`
|
||||
- `new DriveReactSharingError(code, messageKey, options?)` — `options.cause`
|
||||
preserves the original error.
|
||||
- `static is(value): value is DriveReactSharingError` — brand-aware guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The brand symbol is unique per module evaluation; consumers MUST NOT
|
||||
construct a `DriveReactSharingError` outside this package (the
|
||||
constructor is exported only to allow shared error helpers in this
|
||||
workspace to throw it). The seven codes are intentionally split into
|
||||
input-validation versus action-wrapper buckets so UI can render
|
||||
field-level messages distinct from optimistic-update rollback toasts.
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/drive-react-sharing`.
|
||||
*
|
||||
* Every failure raised by the React-tier sharing surface (hook actions,
|
||||
* component callbacks) maps to one of these codes. The underlying
|
||||
* `DriveShareError` from `@suaveplan/drive-sharing` — or any thrown value —
|
||||
* is preserved on `cause` so consumers can walk the chain.
|
||||
*/
|
||||
export const DRIVE_REACT_SHARING_CODES = defineCodes("DRIVE_REACT_SHARING", [
|
||||
"INVALID_RECIPIENT",
|
||||
"PERMISSION_DENIED",
|
||||
"GRANT_NOT_FOUND",
|
||||
"ADD_FAILED",
|
||||
"REVOKE_FAILED",
|
||||
"UPDATE_FAILED",
|
||||
"REFRESH_FAILED",
|
||||
] as const);
|
||||
|
||||
export type DriveReactSharingErrorCode =
|
||||
(typeof DRIVE_REACT_SHARING_CODES)[keyof typeof DRIVE_REACT_SHARING_CODES];
|
||||
|
||||
const BRAND: unique symbol = Symbol("@suaveplan/drive-react-sharing.error");
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for the React-tier sharing package.
|
||||
* Carries a brand symbol so cross-realm `instanceof` failures still permit
|
||||
* defence-in-depth via `DriveReactSharingError.is(value)`.
|
||||
*/
|
||||
export class DriveReactSharingError extends SuaveplanError<DriveReactSharingErrorCode> {
|
||||
public readonly [BRAND]: true;
|
||||
|
||||
public constructor(
|
||||
code: DriveReactSharingErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this[BRAND] = true;
|
||||
}
|
||||
|
||||
public static is(value: unknown): value is DriveReactSharingError {
|
||||
return (
|
||||
value instanceof DriveReactSharingError &&
|
||||
(value as DriveReactSharingError)[BRAND] === true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useSharing } from "./use-sharing.js";
|
||||
@@ -0,0 +1,126 @@
|
||||
# `useSharing` hook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`useSharing` is the React-tier binding for `@suaveplan/drive-sharing`. It
|
||||
holds a local list of `ShareRecipient` rows that mirror the active grants
|
||||
on a partition, and exposes four async actions (`addRecipient`,
|
||||
`removeRecipient`, `updatePermission`, `refresh`) that delegate to the
|
||||
real engine — no in-memory stub, no mocks. After every successful action
|
||||
the hook re-syncs from `GrantManager.list({ partitionId, active: true })`
|
||||
so optimistic UI never drifts from the engine's truth.
|
||||
|
||||
## Features
|
||||
|
||||
- Wires directly to live `PartitionManager`, `GrantManager`, and
|
||||
`TokenManager` instances supplied by the caller. The package does not
|
||||
own a partition store.
|
||||
- AbortSignal threaded through every action. A pre-aborted signal raises
|
||||
a `DriveReactSharingError` before any engine mutation.
|
||||
- Surfaces failures via both `result.error` (for inline rendering) and
|
||||
`throw` (for promise-chain orchestration). Underlying engine errors
|
||||
are preserved on `cause`.
|
||||
- Emits three OTel counters: `drive.react.sharing.recipients_added_total`,
|
||||
`drive.react.sharing.recipients_removed_total`,
|
||||
`drive.react.sharing.permissions_updated_total{permission}`.
|
||||
- Empty-string recipient ids raise `INVALID_RECIPIENT` synchronously.
|
||||
- Permission update is a revoke-then-create round-trip because the engine
|
||||
has no atomic mutate API on `GrantManager`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
PartitionManager,
|
||||
TokenManager,
|
||||
userId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
import { useSharing } from "@suaveplan/drive-react-sharing";
|
||||
|
||||
const partitions = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
const grants = new GrantManager();
|
||||
const tokens = new TokenManager();
|
||||
|
||||
function PanelWidget({ partitionId }: { partitionId: string }) {
|
||||
const sharing = useSharing({
|
||||
partitionId,
|
||||
grantorId: userId("alice"),
|
||||
partitions,
|
||||
grants,
|
||||
tokens,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sharing.addRecipient("bob", "read")}
|
||||
>
|
||||
Add bob
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Composing with `AbortController` so a parent dialog can cancel inflight
|
||||
mutations on close:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from "react";
|
||||
|
||||
function Composer({
|
||||
partitionId,
|
||||
sharing,
|
||||
}: {
|
||||
partitionId: string;
|
||||
sharing: ReturnType<typeof useSharing>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
return () => ctrl.abort();
|
||||
}, [partitionId]);
|
||||
|
||||
async function bulkPromote(ids: readonly string[]): Promise<void> {
|
||||
const ctrl = new AbortController();
|
||||
for (const id of ids) {
|
||||
await sharing.updatePermission(id, "admin", ctrl.signal);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useSharing(options: UseSharingOptions): UseSharingResult`
|
||||
- `options.partitionId: string` — partition the grants belong to.
|
||||
- `options.grantorId: UserId` — typically the current user.
|
||||
- `options.partitions: PartitionManager` — engine partition manager.
|
||||
- `options.grants: GrantManager` — engine grant manager.
|
||||
- `options.tokens: TokenManager` — engine token manager.
|
||||
- `options.initialRecipients?: readonly ShareRecipient[]` — optional
|
||||
SSR / Storybook seed; when omitted the hook seeds from
|
||||
`grants.list({ partitionId, active: true })`.
|
||||
- Returns `UseSharingResult` — `{ recipients, loading, error,
|
||||
addRecipient(id, perm, signal?), removeRecipient(grantId, signal?),
|
||||
updatePermission(grantId, perm, signal?), refresh(signal?) }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The recipient list is refreshed by re-reading from the engine after
|
||||
every mutation rather than spliced in place. This keeps the hook free
|
||||
of merge-conflict logic when multiple components share the same
|
||||
`GrantManager`. `primaryPermission` on each row is computed at projection
|
||||
time from a fixed tier ranking (`read < write < delete < share <
|
||||
admin`); consumers should still consult the full `permissions` array
|
||||
when making entitlement decisions, but UI badges can render the primary
|
||||
tier directly.
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
PartitionManager,
|
||||
TokenManager,
|
||||
partitionId as toPartitionId,
|
||||
userId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import {
|
||||
DRIVE_REACT_SHARING_CODES,
|
||||
DriveReactSharingError,
|
||||
} from "../../error-codes.js";
|
||||
import { resetDriveReactSharingInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
UseSharingOptions,
|
||||
UseSharingResult,
|
||||
} from "../../types/sharing.types.js";
|
||||
import { useSharing } from "./use-sharing.js";
|
||||
|
||||
type ResultRef = { current: UseSharingResult | null };
|
||||
|
||||
function buildOptions(overrides: Partial<UseSharingOptions> = {}): {
|
||||
options: UseSharingOptions;
|
||||
partitions: PartitionManager;
|
||||
grants: GrantManager;
|
||||
tokens: TokenManager;
|
||||
} {
|
||||
const partitions = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
const grants = overrides.grants ?? new GrantManager();
|
||||
const tokens = new TokenManager();
|
||||
const options: UseSharingOptions = {
|
||||
partitionId: "partition-1",
|
||||
grantorId: userId("alice"),
|
||||
partitions,
|
||||
grants,
|
||||
tokens,
|
||||
...overrides,
|
||||
};
|
||||
return { options, partitions, grants, tokens };
|
||||
}
|
||||
|
||||
function seedGrant(
|
||||
grants: GrantManager,
|
||||
partitionRaw: string,
|
||||
grantee: string,
|
||||
permissions: readonly ("read" | "write" | "admin")[],
|
||||
expiresAt?: number
|
||||
): void {
|
||||
grants.create({
|
||||
grantorId: userId("alice"),
|
||||
granteeId: userId(grantee),
|
||||
partitionId: toPartitionId(partitionRaw),
|
||||
permissions,
|
||||
...(expiresAt !== undefined ? { expiresAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function Probe({
|
||||
options,
|
||||
out,
|
||||
}: {
|
||||
options: UseSharingOptions;
|
||||
out: ResultRef;
|
||||
}): null {
|
||||
const result = useSharing(options);
|
||||
out.current = result;
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetDriveReactSharingInstruments();
|
||||
});
|
||||
|
||||
describe("useSharing", () => {
|
||||
it("seeds recipients from the engine on mount", { timeout: 5000 }, () => {
|
||||
const { options, grants } = buildOptions();
|
||||
seedGrant(grants, options.partitionId, "bob", ["read", "write"]);
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
expect(out.current?.recipients.length).toBe(1);
|
||||
expect(out.current?.recipients[0]?.primaryPermission).toBe("write");
|
||||
expect(out.current?.loading).toBe(false);
|
||||
expect(out.current?.error).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts initialRecipients seed without touching engine", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(
|
||||
<Probe
|
||||
options={{
|
||||
...options,
|
||||
initialRecipients: [
|
||||
{
|
||||
grantId: "g-1",
|
||||
recipientId: userId("seed"),
|
||||
permissions: ["read"],
|
||||
primaryPermission: "read",
|
||||
createdAt: 0,
|
||||
revoked: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
out={out}
|
||||
/>
|
||||
);
|
||||
expect(out.current?.recipients[0]?.grantId).toBe("g-1");
|
||||
});
|
||||
|
||||
it("addRecipient creates a grant and re-syncs recipients", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "write");
|
||||
});
|
||||
expect(out.current?.recipients.length).toBe(1);
|
||||
expect(out.current?.recipients[0]?.recipientId).toBe("bob");
|
||||
expect(out.current?.recipients[0]?.primaryPermission).toBe("write");
|
||||
expect(out.current?.error).toBeNull();
|
||||
});
|
||||
|
||||
it("addRecipient rejects empty recipient id with INVALID_RECIPIENT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
let caught: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.addRecipient(" ", "read");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
});
|
||||
expect(DriveReactSharingError.is(caught)).toBe(true);
|
||||
expect((caught as DriveReactSharingError).code).toBe(
|
||||
DRIVE_REACT_SHARING_CODES.INVALID_RECIPIENT
|
||||
);
|
||||
expect(out.current?.error?.code).toBe(
|
||||
DRIVE_REACT_SHARING_CODES.INVALID_RECIPIENT
|
||||
);
|
||||
});
|
||||
|
||||
it("addRecipient wraps engine failure as ADD_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const grants = new GrantManager({ maxEntries: 0 });
|
||||
const { options } = buildOptions({ grants });
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.ADD_FAILED);
|
||||
expect(caught?.cause).toBeDefined();
|
||||
});
|
||||
|
||||
it("addRecipient honours pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.addRecipient("bob", "read", ctrl.signal);
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.ADD_FAILED);
|
||||
});
|
||||
|
||||
it("removeRecipient revokes the grant and updates list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
});
|
||||
const grantId = out.current?.recipients[0]?.grantId as string;
|
||||
await act(async () => {
|
||||
await out.current?.removeRecipient(grantId);
|
||||
});
|
||||
expect(out.current?.recipients.length).toBe(0);
|
||||
});
|
||||
|
||||
it("removeRecipient on missing grant throws GRANT_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.removeRecipient("does-not-exist");
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.GRANT_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("removeRecipient honours pre-aborted signal as REVOKE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
});
|
||||
const grantId = out.current?.recipients[0]?.grantId as string;
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.removeRecipient(grantId, ctrl.signal);
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.REVOKE_FAILED);
|
||||
});
|
||||
|
||||
it("updatePermission revokes existing + creates replacement", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
});
|
||||
const original = out.current?.recipients[0]?.grantId as string;
|
||||
await act(async () => {
|
||||
await out.current?.updatePermission(original, "admin");
|
||||
});
|
||||
expect(out.current?.recipients.length).toBe(1);
|
||||
expect(out.current?.recipients[0]?.primaryPermission).toBe("admin");
|
||||
expect(out.current?.recipients[0]?.grantId).not.toBe(original);
|
||||
});
|
||||
|
||||
it("updatePermission on missing grant throws GRANT_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.updatePermission("nope", "admin");
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.GRANT_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("updatePermission wraps engine failure as UPDATE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const grants = new GrantManager({ maxEntries: 1 });
|
||||
const { options } = buildOptions({ grants });
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
});
|
||||
const id = out.current?.recipients[0]?.grantId as string;
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.updatePermission(id, "admin");
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.UPDATE_FAILED);
|
||||
});
|
||||
|
||||
it("updatePermission honours pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
await act(async () => {
|
||||
await out.current?.addRecipient("bob", "read");
|
||||
});
|
||||
const id = out.current?.recipients[0]?.grantId as string;
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.updatePermission(id, "admin", ctrl.signal);
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.UPDATE_FAILED);
|
||||
});
|
||||
|
||||
it("updatePermission preserves expiresAt on the recreated grant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options, grants } = buildOptions();
|
||||
seedGrant(
|
||||
grants,
|
||||
options.partitionId,
|
||||
"bob",
|
||||
["read"],
|
||||
Date.now() + 60_000
|
||||
);
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
const id = out.current?.recipients[0]?.grantId as string;
|
||||
await act(async () => {
|
||||
await out.current?.updatePermission(id, "write");
|
||||
});
|
||||
expect(out.current?.recipients[0]?.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("refresh re-reads from engine", { timeout: 5000 }, async () => {
|
||||
const { options, grants } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
seedGrant(grants, options.partitionId, "carol", ["read"]);
|
||||
expect(out.current?.recipients.length).toBe(0);
|
||||
await act(async () => {
|
||||
await out.current?.refresh();
|
||||
});
|
||||
expect(out.current?.recipients.length).toBe(1);
|
||||
});
|
||||
|
||||
it("refresh honours pre-aborted signal as REFRESH_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
let caught: DriveReactSharingError | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await out.current?.refresh(ctrl.signal);
|
||||
} catch (e) {
|
||||
caught = e as DriveReactSharingError;
|
||||
}
|
||||
});
|
||||
expect(caught?.code).toBe(DRIVE_REACT_SHARING_CODES.REFRESH_FAILED);
|
||||
});
|
||||
|
||||
it("unmount before async resolution does not setState", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { options } = buildOptions();
|
||||
const out: ResultRef = { current: null };
|
||||
const view = renderComponent(<Probe options={options} out={out} />);
|
||||
const ref = out.current as UseSharingResult;
|
||||
view.unmount();
|
||||
await ref.addRecipient("late", "read");
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("primaryPermission picks highest tier across all permissions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { options, grants } = buildOptions();
|
||||
seedGrant(grants, options.partitionId, "bob", [
|
||||
"read",
|
||||
"admin",
|
||||
"write",
|
||||
]);
|
||||
const out: ResultRef = { current: null };
|
||||
renderComponent(<Probe options={options} out={out} />);
|
||||
expect(out.current?.recipients[0]?.primaryPermission).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
type Grant,
|
||||
type GrantId,
|
||||
type PartitionId,
|
||||
type SharePermission,
|
||||
grantId as toGrantId,
|
||||
partitionId as toPartitionId,
|
||||
userId as toUserId,
|
||||
type UserId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DRIVE_REACT_SHARING_CODES,
|
||||
DriveReactSharingError,
|
||||
} from "../../error-codes.js";
|
||||
import { getDriveReactSharingInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
ShareRecipient,
|
||||
UseSharingOptions,
|
||||
UseSharingResult,
|
||||
} from "../../types/sharing.types.js";
|
||||
|
||||
/** Permission tier ordering for `primaryPermission` resolution. */
|
||||
const TIER_RANK: Record<SharePermission, number> = {
|
||||
read: 1,
|
||||
write: 2,
|
||||
delete: 3,
|
||||
share: 4,
|
||||
admin: 5,
|
||||
};
|
||||
|
||||
function primaryOf(permissions: readonly SharePermission[]): SharePermission {
|
||||
let best: SharePermission = "read";
|
||||
let bestRank = 0;
|
||||
for (const p of permissions) {
|
||||
const rank = TIER_RANK[p];
|
||||
if (rank > bestRank) {
|
||||
best = p;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function projectGrant(grant: Grant): ShareRecipient {
|
||||
return {
|
||||
grantId: grant.id,
|
||||
recipientId: grant.granteeId,
|
||||
permissions: grant.permissions,
|
||||
primaryPermission: primaryOf(grant.permissions),
|
||||
createdAt: grant.createdAt,
|
||||
...(grant.expiresAt !== undefined
|
||||
? { expiresAt: grant.expiresAt }
|
||||
: {}),
|
||||
revoked: grant.revoked,
|
||||
};
|
||||
}
|
||||
|
||||
function loadRecipients(options: UseSharingOptions): readonly ShareRecipient[] {
|
||||
const pid = toPartitionId(options.partitionId) as PartitionId;
|
||||
return options.grants
|
||||
.list({ partitionId: pid, active: true })
|
||||
.map(projectGrant);
|
||||
}
|
||||
|
||||
function wrap(
|
||||
code:
|
||||
| typeof DRIVE_REACT_SHARING_CODES.ADD_FAILED
|
||||
| typeof DRIVE_REACT_SHARING_CODES.REVOKE_FAILED
|
||||
| typeof DRIVE_REACT_SHARING_CODES.UPDATE_FAILED
|
||||
| typeof DRIVE_REACT_SHARING_CODES.REFRESH_FAILED,
|
||||
messageKey: string,
|
||||
cause: unknown
|
||||
): DriveReactSharingError {
|
||||
return new DriveReactSharingError(code, messageKey, { cause });
|
||||
}
|
||||
|
||||
function checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) {
|
||||
throw new DriveReactSharingError(
|
||||
DRIVE_REACT_SHARING_CODES.ADD_FAILED,
|
||||
"drive-react-sharing.aborted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook binding a partition's share state to the live engine.
|
||||
*
|
||||
* Holds an in-memory `ShareRecipient[]` that mirrors active grants for the
|
||||
* supplied `partitionId`, plus async action callbacks (`addRecipient`,
|
||||
* `removeRecipient`, `updatePermission`, `refresh`) that delegate to
|
||||
* `@suaveplan/drive-sharing` and re-sync state on success. Every action
|
||||
* accepts an optional `AbortSignal`. Errors are surfaced both via
|
||||
* `result.error` and by re-throwing so callers can render inline.
|
||||
*/
|
||||
export function useSharing(options: UseSharingOptions): UseSharingResult {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const [recipients, setRecipients] = useState<readonly ShareRecipient[]>(
|
||||
() => options.initialRecipients ?? loadRecipients(options)
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<DriveReactSharingError | null>(null);
|
||||
|
||||
// Track mount status so async tasks do not setState after unmount.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const safeSetRecipients = useCallback(
|
||||
(next: readonly ShareRecipient[]): void => {
|
||||
if (mountedRef.current) setRecipients(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const safeSetLoading = useCallback((next: boolean): void => {
|
||||
if (mountedRef.current) setLoading(next);
|
||||
}, []);
|
||||
const safeSetError = useCallback(
|
||||
(next: DriveReactSharingError | null): void => {
|
||||
if (mountedRef.current) setError(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (signal?: AbortSignal): Promise<void> => {
|
||||
safeSetLoading(true);
|
||||
try {
|
||||
checkAborted(signal);
|
||||
const next = loadRecipients(optionsRef.current);
|
||||
safeSetRecipients(next);
|
||||
safeSetError(null);
|
||||
} catch (err) {
|
||||
const wrapped = wrap(
|
||||
DRIVE_REACT_SHARING_CODES.REFRESH_FAILED,
|
||||
"drive-react-sharing.refresh-failed",
|
||||
err
|
||||
);
|
||||
safeSetError(wrapped);
|
||||
throw wrapped;
|
||||
} finally {
|
||||
safeSetLoading(false);
|
||||
}
|
||||
},
|
||||
[safeSetError, safeSetLoading, safeSetRecipients]
|
||||
);
|
||||
|
||||
const addRecipient = useCallback(
|
||||
async (
|
||||
recipientId: string,
|
||||
permission: SharePermission,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> => {
|
||||
if (recipientId.trim() === "") {
|
||||
const e = new DriveReactSharingError(
|
||||
DRIVE_REACT_SHARING_CODES.INVALID_RECIPIENT,
|
||||
"drive-react-sharing.invalid-recipient"
|
||||
);
|
||||
safeSetError(e);
|
||||
throw e;
|
||||
}
|
||||
safeSetLoading(true);
|
||||
try {
|
||||
checkAborted(signal);
|
||||
const opts = optionsRef.current;
|
||||
opts.grants.create({
|
||||
grantorId: opts.grantorId,
|
||||
granteeId: toUserId(recipientId) as UserId,
|
||||
partitionId: toPartitionId(opts.partitionId) as PartitionId,
|
||||
permissions: [permission],
|
||||
});
|
||||
getDriveReactSharingInstruments().recipientsAddedTotal.add(1, {
|
||||
permission,
|
||||
});
|
||||
safeSetRecipients(loadRecipients(opts));
|
||||
safeSetError(null);
|
||||
} catch (err) {
|
||||
const wrapped = wrap(
|
||||
DRIVE_REACT_SHARING_CODES.ADD_FAILED,
|
||||
"drive-react-sharing.add-failed",
|
||||
err
|
||||
);
|
||||
safeSetError(wrapped);
|
||||
throw wrapped;
|
||||
} finally {
|
||||
safeSetLoading(false);
|
||||
}
|
||||
},
|
||||
[safeSetError, safeSetLoading, safeSetRecipients]
|
||||
);
|
||||
|
||||
const removeRecipient = useCallback(
|
||||
async (grantId: string, signal?: AbortSignal): Promise<void> => {
|
||||
const opts = optionsRef.current;
|
||||
const existing = opts.grants.get(toGrantId(grantId) as GrantId);
|
||||
if (existing === undefined) {
|
||||
const e = new DriveReactSharingError(
|
||||
DRIVE_REACT_SHARING_CODES.GRANT_NOT_FOUND,
|
||||
"drive-react-sharing.grant-not-found"
|
||||
);
|
||||
safeSetError(e);
|
||||
throw e;
|
||||
}
|
||||
safeSetLoading(true);
|
||||
try {
|
||||
checkAborted(signal);
|
||||
opts.grants.revoke(toGrantId(grantId) as GrantId);
|
||||
getDriveReactSharingInstruments().recipientsRemovedTotal.add(1);
|
||||
safeSetRecipients(loadRecipients(opts));
|
||||
safeSetError(null);
|
||||
} catch (err) {
|
||||
const wrapped = wrap(
|
||||
DRIVE_REACT_SHARING_CODES.REVOKE_FAILED,
|
||||
"drive-react-sharing.revoke-failed",
|
||||
err
|
||||
);
|
||||
safeSetError(wrapped);
|
||||
throw wrapped;
|
||||
} finally {
|
||||
safeSetLoading(false);
|
||||
}
|
||||
},
|
||||
[safeSetError, safeSetLoading, safeSetRecipients]
|
||||
);
|
||||
|
||||
const updatePermission = useCallback(
|
||||
async (
|
||||
grantId: string,
|
||||
permission: SharePermission,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> => {
|
||||
const opts = optionsRef.current;
|
||||
const existing = opts.grants.get(toGrantId(grantId) as GrantId);
|
||||
if (existing === undefined) {
|
||||
const e = new DriveReactSharingError(
|
||||
DRIVE_REACT_SHARING_CODES.GRANT_NOT_FOUND,
|
||||
"drive-react-sharing.grant-not-found"
|
||||
);
|
||||
safeSetError(e);
|
||||
throw e;
|
||||
}
|
||||
safeSetLoading(true);
|
||||
try {
|
||||
checkAborted(signal);
|
||||
// Revoke + recreate is the engine-supported way to swap
|
||||
// permissions atomically; GrantManager has no mutate API.
|
||||
opts.grants.revoke(existing.id);
|
||||
opts.grants.create({
|
||||
grantorId: existing.grantorId,
|
||||
granteeId: existing.granteeId,
|
||||
partitionId: existing.partitionId,
|
||||
permissions: [permission],
|
||||
...(existing.expiresAt !== undefined
|
||||
? { expiresAt: existing.expiresAt }
|
||||
: {}),
|
||||
});
|
||||
getDriveReactSharingInstruments().permissionsUpdatedTotal.add(
|
||||
1,
|
||||
{ permission }
|
||||
);
|
||||
safeSetRecipients(loadRecipients(opts));
|
||||
safeSetError(null);
|
||||
} catch (err) {
|
||||
const wrapped = wrap(
|
||||
DRIVE_REACT_SHARING_CODES.UPDATE_FAILED,
|
||||
"drive-react-sharing.update-failed",
|
||||
err
|
||||
);
|
||||
safeSetError(wrapped);
|
||||
throw wrapped;
|
||||
} finally {
|
||||
safeSetLoading(false);
|
||||
}
|
||||
},
|
||||
[safeSetError, safeSetLoading, safeSetRecipients]
|
||||
);
|
||||
|
||||
return {
|
||||
recipients,
|
||||
loading,
|
||||
error,
|
||||
addRecipient,
|
||||
removeRecipient,
|
||||
updatePermission,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
43
packages/networking/drive/drive-react-sharing/src/index.ts
Normal file
43
packages/networking/drive/drive-react-sharing/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Components
|
||||
export {
|
||||
FileAccessList,
|
||||
type FileAccessListProps,
|
||||
} from "./components/file-access-list/file-access-list.js";
|
||||
export {
|
||||
PartitionBadge,
|
||||
type PartitionBadgeProps,
|
||||
} from "./components/partition-badge/partition-badge.js";
|
||||
export {
|
||||
ShareDialog,
|
||||
type ShareDialogLabels,
|
||||
type ShareDialogProps,
|
||||
} from "./components/share-dialog/share-dialog.js";
|
||||
// Errors
|
||||
export {
|
||||
DRIVE_REACT_SHARING_CODES,
|
||||
DriveReactSharingError,
|
||||
type DriveReactSharingErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Hook
|
||||
export { useSharing } from "./hooks/use-sharing/use-sharing.js";
|
||||
// OTel
|
||||
export {
|
||||
type DriveReactSharingInstruments,
|
||||
getDriveReactSharingInstruments,
|
||||
resetDriveReactSharingInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
// Types
|
||||
export type {
|
||||
AccessEntry,
|
||||
Grant,
|
||||
GrantId,
|
||||
Partition,
|
||||
PartitionBadgeType,
|
||||
PartitionId,
|
||||
PartitionType,
|
||||
SharePermission,
|
||||
ShareRecipient,
|
||||
UserId,
|
||||
UseSharingOptions,
|
||||
UseSharingResult,
|
||||
} from "./types/sharing.types.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/drive-react-sharing` emits three counters covering every
|
||||
mutation the `useSharing` hook can dispatch. They are useful both for
|
||||
operational dashboards (how often are admins adding versus removing
|
||||
recipients?) and for product analytics (which permission tier is
|
||||
trending?). Instruments are created lazily so this module is safe to
|
||||
import inside a tree-shaken bundle even when the consumer never
|
||||
configures a `MeterProvider`.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument creation — first `getDriveReactSharingInstruments()`
|
||||
call resolves the meter and caches the counter triple.
|
||||
- `resetDriveReactSharingInstruments()` clears the cache so tests can
|
||||
swap a fresh meter provider per case without leaking instruments.
|
||||
- Counter names follow the `drive.react.sharing.*_total` convention so
|
||||
Prometheus / OTLP backends can aggregate the family cheanly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDriveReactSharingInstruments } from "@suaveplan/drive-react-sharing";
|
||||
|
||||
const { recipientsAddedTotal } = getDriveReactSharingInstruments();
|
||||
recipientsAddedTotal.add(1, { partition: "shared" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Swap meter providers between integration test cases:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { resetDriveReactSharingInstruments } from "@suaveplan/drive-react-sharing";
|
||||
|
||||
afterEach(() => {
|
||||
metrics.disable();
|
||||
resetDriveReactSharingInstruments();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DriveReactSharingInstruments` — `{ recipientsAddedTotal, recipientsRemovedTotal, permissionsUpdatedTotal }`.
|
||||
- `getDriveReactSharingInstruments(): DriveReactSharingInstruments` — lazy accessor.
|
||||
- `resetDriveReactSharingInstruments(): void` — test-only reset.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are deliberately Counters (not Histograms) because each
|
||||
event is a single mutation with no associated duration distribution.
|
||||
Permission updates carry a `permission` attribute so dashboards can
|
||||
distinguish `read` / `write` / `admin` trends; the other counters carry
|
||||
no attributes by default so cardinality remains bounded.
|
||||
|
||||
Lazy creation matters because every project consumer that pulls in the
|
||||
React-tier sharing surface should pay zero instrument overhead until it
|
||||
actually mutates a grant. The cache lives at module scope so the first
|
||||
caller materialises the counters and every subsequent caller pays only
|
||||
a single property lookup. Tests must call `resetDriveReactSharingInstruments`
|
||||
between cases when they swap the global `MeterProvider`; otherwise the
|
||||
cached counters retain references to the prior provider's meter and
|
||||
new metric points never surface in the swapped backend.
|
||||
|
||||
The decision to scope updates by `permission` attribute rather than by
|
||||
recipient id keeps cardinality at exactly five values matching the
|
||||
permission union (`read` / `write` / `delete` / `share` / `admin`),
|
||||
which is friendly to Prometheus label cardinality budgets and avoids
|
||||
the runaway-tag pitfall common in user-id-attributed counters.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instrument cache for `@suaveplan/drive-react-sharing`. Counters are
|
||||
* created lazily on first read so consumers that never install a meter
|
||||
* provider still get the no-op fallback without paying instrument-creation
|
||||
* cost at module load.
|
||||
*/
|
||||
export interface DriveReactSharingInstruments {
|
||||
readonly recipientsAddedTotal: Counter;
|
||||
readonly recipientsRemovedTotal: Counter;
|
||||
readonly permissionsUpdatedTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: DriveReactSharingInstruments | undefined;
|
||||
|
||||
function createInstruments(): DriveReactSharingInstruments {
|
||||
const meter = metrics.getMeter("@suaveplan/drive-react-sharing");
|
||||
return {
|
||||
recipientsAddedTotal: meter.createCounter(
|
||||
"drive.react.sharing.recipients_added_total",
|
||||
{
|
||||
description:
|
||||
"Number of recipients added through useSharing.addRecipient",
|
||||
}
|
||||
),
|
||||
recipientsRemovedTotal: meter.createCounter(
|
||||
"drive.react.sharing.recipients_removed_total",
|
||||
{
|
||||
description:
|
||||
"Number of recipients removed through useSharing.removeRecipient",
|
||||
}
|
||||
),
|
||||
permissionsUpdatedTotal: meter.createCounter(
|
||||
"drive.react.sharing.permissions_updated_total",
|
||||
{
|
||||
description:
|
||||
"Number of recipient permission updates dispatched through useSharing.updatePermission",
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the lazily-cached instrument bundle. */
|
||||
export function getDriveReactSharingInstruments(): DriveReactSharingInstruments {
|
||||
if (cached === undefined) {
|
||||
cached = createInstruments();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cached instrument bundle. Test-only — production code must not
|
||||
* call this. Useful when tests swap the global `MeterProvider` between cases.
|
||||
*/
|
||||
export function resetDriveReactSharingInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Canonical sharing types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single source of truth for the React-tier sharing
|
||||
type surface. It re-exports the canonical `SharePermission`,
|
||||
`PartitionType`, `Grant`, `UserId`, and `PartitionId` types directly
|
||||
from `@suaveplan/drive-sharing` — the engine package owns the literal
|
||||
sets — and adds three projection types (`ShareRecipient`, `AccessEntry`,
|
||||
`UseSharingOptions` / `UseSharingResult`) tailored to the UI surface.
|
||||
|
||||
On main, three overlapping permission names existed: `SharePermission`,
|
||||
`AccessPermission`, and `SharingPermission`. suaveplan consolidates these
|
||||
into the engine-owned `SharePermission` and refuses to ship a second
|
||||
alias.
|
||||
|
||||
## Features
|
||||
|
||||
- Single canonical re-export of `SharePermission` covering every
|
||||
permission tier accepted by the engine (`read | write | delete | share | admin`).
|
||||
- `ShareRecipient` UI projection — flattens an engine `Grant` plus
|
||||
primary permission for badge rendering.
|
||||
- `AccessEntry` — read-only row used by `<FileAccessList />`.
|
||||
- `UseSharingOptions` / `UseSharingResult` — wire the hook to live
|
||||
engine instances (`PartitionManager`, `GrantManager`, `TokenManager`)
|
||||
without forcing consumers to import three packages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SharePermission,
|
||||
ShareRecipient,
|
||||
} from "@suaveplan/drive-react-sharing";
|
||||
|
||||
const tier: SharePermission = "write";
|
||||
function render(recipients: readonly ShareRecipient[]): void {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
UseSharingOptions,
|
||||
UseSharingResult,
|
||||
} from "@suaveplan/drive-react-sharing";
|
||||
|
||||
function buildOptions(
|
||||
partitionId: string,
|
||||
grantor: UseSharingOptions["grantorId"],
|
||||
deps: Pick<UseSharingOptions, "partitions" | "grants" | "tokens">
|
||||
): UseSharingOptions {
|
||||
return { partitionId, grantorId: grantor, ...deps };
|
||||
}
|
||||
|
||||
function summarize(result: UseSharingResult): string {
|
||||
return `${result.recipients.length} recipients (${result.loading ? "loading" : "idle"})`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SharePermission = "read" | "write" | "delete" | "share" | "admin"`
|
||||
(re-exported from `@suaveplan/drive-sharing`).
|
||||
- `type PartitionType = "personal" | "shared" | "public" | "encrypted"`.
|
||||
- `interface ShareRecipient` — UI projection of a `Grant`.
|
||||
- `interface AccessEntry` — read-only access-list row.
|
||||
- `interface UseSharingOptions` — `{ partitionId, grantorId, partitions, grants, tokens, initialRecipients? }`.
|
||||
- `interface UseSharingResult` — `{ recipients, loading, error, addRecipient, removeRecipient, updatePermission, refresh }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ShareRecipient.primaryPermission` is the highest tier present in the
|
||||
flat `permissions` array — chosen for badge colour selection. The hook
|
||||
computes this when constructing recipients from the engine, so consumers
|
||||
should treat the field as authoritative for display purposes but still
|
||||
consult the full `permissions` array for entitlement decisions.
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Canonical type surface for `@suaveplan/drive-react-sharing`.
|
||||
*
|
||||
* The proposal calls for consolidating the three overlapping permission
|
||||
* names that existed on main (`SharePermission` / `AccessPermission` /
|
||||
* `SharingPermission`) into a single canonical union. We achieve that by
|
||||
* importing `SharePermission` directly from `@suaveplan/drive-sharing`
|
||||
* — the engine package owns the canonical literal set — and re-exporting
|
||||
* it from this React-tier package so component consumers only ever
|
||||
* touch one name.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GrantManager,
|
||||
PartitionManager,
|
||||
PartitionType,
|
||||
SharePermission,
|
||||
TokenManager,
|
||||
UserId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
import type { DriveReactSharingError } from "../error-codes.js";
|
||||
|
||||
// --- Re-exports of canonical engine types ------------------------------
|
||||
|
||||
export type {
|
||||
Grant,
|
||||
GrantId,
|
||||
Partition,
|
||||
PartitionId,
|
||||
PartitionType,
|
||||
SharePermission,
|
||||
UserId,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
|
||||
/** Compact, UI-friendly projection of an engine `Grant`. */
|
||||
export interface ShareRecipient {
|
||||
readonly grantId: string;
|
||||
readonly recipientId: UserId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
/** Convenience: highest permission tier for badge rendering. */
|
||||
readonly primaryPermission: SharePermission;
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt?: number;
|
||||
readonly revoked: boolean;
|
||||
}
|
||||
|
||||
/** Read-only access-list row used by `<FileAccessList />`. */
|
||||
export interface AccessEntry {
|
||||
readonly grantId: string;
|
||||
readonly recipientId: UserId;
|
||||
readonly permission: SharePermission;
|
||||
}
|
||||
|
||||
/** Constructor options accepted by {@link useSharing}. */
|
||||
export interface UseSharingOptions {
|
||||
/** The active partition this dialog is scoped to. */
|
||||
readonly partitionId: string;
|
||||
/** Caller-supplied grantor (typically the logged-in user). */
|
||||
readonly grantorId: UserId;
|
||||
/** Engine partition manager — owns the partition store. */
|
||||
readonly partitions: PartitionManager;
|
||||
/** Engine grant manager — owns grant lifecycle. */
|
||||
readonly grants: GrantManager;
|
||||
/** Engine token manager — used by future link-sharing surface. */
|
||||
readonly tokens: TokenManager;
|
||||
/**
|
||||
* Optional initial recipients list. When omitted the hook seeds from
|
||||
* `grants.list({ partitionId })`.
|
||||
*/
|
||||
readonly initialRecipients?: readonly ShareRecipient[];
|
||||
}
|
||||
|
||||
/** Imperative API returned by {@link useSharing}. */
|
||||
export interface UseSharingResult {
|
||||
readonly recipients: readonly ShareRecipient[];
|
||||
readonly loading: boolean;
|
||||
readonly error: DriveReactSharingError | null;
|
||||
addRecipient(
|
||||
recipientId: string,
|
||||
permission: SharePermission,
|
||||
signal?: AbortSignal
|
||||
): Promise<void>;
|
||||
removeRecipient(grantId: string, signal?: AbortSignal): Promise<void>;
|
||||
updatePermission(
|
||||
grantId: string,
|
||||
permission: SharePermission,
|
||||
signal?: AbortSignal
|
||||
): Promise<void>;
|
||||
refresh(signal?: AbortSignal): Promise<void>;
|
||||
}
|
||||
|
||||
/** Partition type literals exposed for `<PartitionBadge />`. */
|
||||
export type PartitionBadgeType = PartitionType;
|
||||
@@ -0,0 +1,12 @@
|
||||
// React-tier polyfills. Base webcrypto comes from `@suaveplan/testing/polyfills`;
|
||||
// this file adds the `IS_REACT_ACT_ENVIRONMENT` flag React DOM looks for when
|
||||
// state updates happen outside an explicit `act(...)` wrapper.
|
||||
|
||||
interface ReactActEnvWindow {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
const win = globalThis as unknown as ReactActEnvWindow;
|
||||
if (win.IS_REACT_ACT_ENVIRONMENT === undefined) {
|
||||
win.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.lib.json"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../foundation/core/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
55
packages/networking/drive/drive-react-sharing/vite.config.ts
Normal file
55
packages/networking/drive/drive-react-sharing/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/drive-react-sharing",
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"hooks/use-sharing/use-sharing":
|
||||
"src/hooks/use-sharing/use-sharing.ts",
|
||||
"components/share-dialog/share-dialog":
|
||||
"src/components/share-dialog/share-dialog.tsx",
|
||||
"components/partition-badge/partition-badge":
|
||||
"src/components/partition-badge/partition-badge.tsx",
|
||||
"components/file-access-list/file-access-list":
|
||||
"src/components/file-access-list/file-access-list.tsx",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/drive-sharing",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"tslib",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: [
|
||||
"@suaveplan/testing/polyfills",
|
||||
"./tests/setup/polyfills.ts",
|
||||
],
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user