Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View 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"
]
}

View File

@@ -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.

View File

@@ -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} />
);

View File

@@ -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");
});
});

View File

@@ -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";

View File

@@ -0,0 +1,4 @@
export {
FileAccessList,
type FileAccessListProps,
} from "./file-access-list.js";

View File

@@ -0,0 +1,4 @@
export {
PartitionBadge,
type PartitionBadgeProps,
} from "./partition-badge.js";

View File

@@ -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.

View File

@@ -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 />
);

View File

@@ -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");
});
});

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
export {
ShareDialog,
type ShareDialogLabels,
type ShareDialogProps,
} from "./share-dialog.js";

View File

@@ -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.

View File

@@ -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)}
/>
</>
);
};

View File

@@ -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();
});
});

View File

@@ -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 ?? {}),
},
};
}

View File

@@ -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.

View File

@@ -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
);
}
}

View File

@@ -0,0 +1 @@
export { useSharing } from "./use-sharing.js";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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,
};
}

View 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";

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.lib.json"
}

View File

@@ -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"
]
}

View 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:/,
],
},
},
});

View File

@@ -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,
},
});