@startsimpli/ui 0.4.148 → 0.4.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.148",
3
+ "version": "0.4.150",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -56,8 +56,8 @@
56
56
  "@codemirror/state": "^6.0.0",
57
57
  "@codemirror/view": "^6.0.0",
58
58
  "@startsimpli/auth": "^0.4.39",
59
- "@startsimpli/api": "^0.5.66",
60
- "@startsimpli/i18n": "^0.1.3"
59
+ "@startsimpli/i18n": "^0.1.3",
60
+ "@startsimpli/api": "^0.5.66"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@startsimpli/api": {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * NavDrawer — the phone-width home of an app's left rail (bd startsim-g2m75).
3
+ *
4
+ * At 390px a fixed 240px rail left the content column ~52px wide. Below the
5
+ * breakpoint the rail must become a menu button that opens the same nav as a
6
+ * modal drawer: focus trapped inside, Escape closes, choosing a link closes.
7
+ * jsdom has no viewport, so width is proven in Playwright; this pins behaviour.
8
+ */
9
+ import { useState } from 'react'
10
+ import { render, screen, fireEvent } from '@testing-library/react'
11
+ import { NavDrawer, NavDrawerButton } from '../nav-drawer'
12
+
13
+ function Harness() {
14
+ const [open, setOpen] = useState(false)
15
+ return (
16
+ <>
17
+ <NavDrawerButton open={open} onOpenChange={setOpen} />
18
+ <NavDrawer open={open} onOpenChange={setOpen} title="Navigation">
19
+ <a href="/t/topic" onClick={() => setOpen(false)}>
20
+ Topics
21
+ </a>
22
+ <button type="button">Log out</button>
23
+ </NavDrawer>
24
+ </>
25
+ )
26
+ }
27
+
28
+ describe('NavDrawer', () => {
29
+ it('is closed until the menu button is pressed, and says so', () => {
30
+ render(<Harness />)
31
+ const btn = screen.getByRole('button', { name: /open navigation/i })
32
+ expect(btn).toHaveAttribute('aria-expanded', 'false')
33
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
34
+ })
35
+
36
+ it('opens the nav as a labelled modal dialog', () => {
37
+ render(<Harness />)
38
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
39
+ const dialog = screen.getByRole('dialog', { name: 'Navigation' })
40
+ expect(dialog).toContainElement(screen.getByRole('link', { name: 'Topics' }))
41
+ expect(screen.getByRole('button', { name: /open navigation/i, hidden: true })).toHaveAttribute(
42
+ 'aria-expanded',
43
+ 'true',
44
+ )
45
+ })
46
+
47
+ it('moves focus into the drawer when it opens', () => {
48
+ render(<Harness />)
49
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
50
+ expect(screen.getByRole('dialog')).toContainElement(document.activeElement as HTMLElement)
51
+ })
52
+
53
+ it('closes on Escape', () => {
54
+ render(<Harness />)
55
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
56
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
57
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('closes from its own close button', () => {
61
+ render(<Harness />)
62
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
63
+ fireEvent.click(screen.getByRole('button', { name: /close navigation/i }))
64
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
65
+ })
66
+
67
+ it('closes when a nav link is chosen', () => {
68
+ render(<Harness />)
69
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
70
+ fireEvent.click(screen.getByRole('link', { name: 'Topics' }))
71
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
72
+ })
73
+ })
@@ -5,3 +5,4 @@ export * from './app-shell'
5
5
  export * from './grouped-nav'
6
6
  export * from './entity-switcher'
7
7
  export * from './breadcrumbs'
8
+ export * from './nav-drawer'
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
5
+ import { Menu, X } from 'lucide-react';
6
+ import { cn } from '../../lib/utils';
7
+
8
+ /**
9
+ * The phone-width home of an app's left rail (bd startsim-g2m75).
10
+ *
11
+ * A fixed 240px rail beside the content works on a laptop and leaves ~52px of
12
+ * content at 390px. Below the app's breakpoint the rail should become a
13
+ * <NavDrawerButton/> that opens the SAME nav inside a <NavDrawer/>: a modal
14
+ * left drawer built on Radix Dialog, so focus is trapped, Escape and an outside
15
+ * tap close it, focus returns to the button, and the page behind can't scroll.
16
+ *
17
+ * The open state is the caller's client state — never a search param, since a
18
+ * Next layout doesn't re-render on soft navigation. Close it from the nav
19
+ * link's own onClick (it then also closes on a tap of the current page, which a
20
+ * pathname effect would miss).
21
+ *
22
+ * const [open, setOpen] = useState(false)
23
+ * <div className="md:hidden"><NavDrawerButton open={open} onOpenChange={setOpen} /></div>
24
+ * <NavDrawer open={open} onOpenChange={setOpen} title="Navigation">{nav}</NavDrawer>
25
+ */
26
+ export interface NavDrawerProps {
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ /** Accessible name of the drawer (visually hidden). */
30
+ title?: string;
31
+ /** Classes for the drawer panel — e.g. a width or a dark rail background. */
32
+ className?: string;
33
+ children: ReactNode;
34
+ }
35
+
36
+ export function NavDrawer({
37
+ open,
38
+ onOpenChange,
39
+ title = 'Navigation',
40
+ className,
41
+ children,
42
+ }: NavDrawerProps) {
43
+ return (
44
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
45
+ <DialogPrimitive.Portal>
46
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40" />
47
+ <DialogPrimitive.Content
48
+ aria-describedby={undefined}
49
+ className={cn(
50
+ 'fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-hidden bg-white shadow-xl focus:outline-none',
51
+ className,
52
+ )}
53
+ >
54
+ <DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
55
+ {children}
56
+ <DialogPrimitive.Close
57
+ aria-label="Close navigation"
58
+ className="absolute right-2 top-2 flex h-10 w-10 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
59
+ >
60
+ <X className="h-5 w-5" aria-hidden />
61
+ </DialogPrimitive.Close>
62
+ </DialogPrimitive.Content>
63
+ </DialogPrimitive.Portal>
64
+ </DialogPrimitive.Root>
65
+ );
66
+ }
67
+
68
+ export interface NavDrawerButtonProps {
69
+ open: boolean;
70
+ onOpenChange: (open: boolean) => void;
71
+ className?: string;
72
+ /** Accessible label (default "Open navigation"). */
73
+ label?: string;
74
+ }
75
+
76
+ /** The menu button that opens a <NavDrawer/>. Hide it at the rail's breakpoint. */
77
+ export function NavDrawerButton({
78
+ open,
79
+ onOpenChange,
80
+ className,
81
+ label = 'Open navigation',
82
+ }: NavDrawerButtonProps) {
83
+ return (
84
+ <button
85
+ type="button"
86
+ aria-label={label}
87
+ aria-expanded={open}
88
+ aria-haspopup="dialog"
89
+ onClick={() => onOpenChange(!open)}
90
+ className={cn(
91
+ 'flex h-10 w-10 items-center justify-center rounded-md text-gray-700 transition hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
92
+ className,
93
+ )}
94
+ >
95
+ <Menu className="h-5 w-5" aria-hidden />
96
+ </button>
97
+ );
98
+ }
@@ -33,6 +33,7 @@ import { Button } from '../components/ui/button';
33
33
  import { Input } from '../components/ui/input';
34
34
  import { notify } from '../components/toast/use-notify';
35
35
  import { useNow } from '../hooks/use-now';
36
+ import { can } from './permissions';
36
37
  import {
37
38
  edgeEndpoints,
38
39
  edgesForSide,
@@ -191,6 +192,9 @@ export function LinkPanel({
191
192
  dateAttributeFor,
192
193
  }: LinkPanelProps) {
193
194
  const qc = useQueryClient();
195
+ // Adding or removing a link is editing the record (bd startsim-768w.71). A
196
+ // tenant that sends no permissions keeps the controls, as before.
197
+ const mayLink = can(record, 'canLink', true);
194
198
  const [picking, setPicking] = useState(false);
195
199
  const [search, setSearch] = useState('');
196
200
  const [busy, setBusy] = useState(false);
@@ -276,7 +280,7 @@ export function LinkPanel({
276
280
  </h3>
277
281
  {description ? <p className="mt-0.5 text-xs text-neutral-500">{description}</p> : null}
278
282
  </div>
279
- <Button
283
+ {mayLink ? <Button
280
284
  size="sm"
281
285
  variant={picking ? 'outline' : 'default'}
282
286
  onClick={() => {
@@ -291,7 +295,7 @@ export function LinkPanel({
291
295
  <Plus className="mr-1 h-3.5 w-3.5" aria-hidden /> Add
292
296
  </>
293
297
  )}
294
- </Button>
298
+ </Button> : null}
295
299
  </header>
296
300
 
297
301
  {picking ? (
@@ -373,7 +377,7 @@ export function LinkPanel({
373
377
  </span>
374
378
  )}
375
379
  </span>
376
- <button
380
+ {mayLink ? <button
377
381
  type="button"
378
382
  disabled={busy}
379
383
  onClick={() => unlink(edge.id, label)}
@@ -382,7 +386,7 @@ export function LinkPanel({
382
386
  className="shrink-0 rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
383
387
  >
384
388
  <X className="h-4 w-4" aria-hidden />
385
- </button>
389
+ </button> : null}
386
390
  </li>
387
391
  );
388
392
  })}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Controls follow the record's `permissions` object (bd startsim-768w.71), and a
3
+ * tenant that sends none keeps the old behaviour.
4
+ */
5
+ import { render, screen } from '@testing-library/react'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
+ import { LinkPanel, type LinkPanelApi } from '../LinkPanel'
8
+ import { linkSidesFor, type LinkSide } from '../links'
9
+ import { can, permissionsOf, SHARE_LEVELS, type RecordPermissions } from '../permissions'
10
+ import type { EntityRecordShape, EntityTypeShape, RelationshipEdgeShape } from '../types'
11
+
12
+ const PRODUCT: EntityTypeShape = { id: 't1', key: 'product', label: 'Products', attributes: [] }
13
+ const PATENT: EntityTypeShape = { id: 't2', key: 'patent', label: 'Patents', attributes: [] }
14
+ const side = linkSidesFor(PRODUCT, [{ key: 'marked_with', sourceType: 't1', targetType: 't2' }], [
15
+ PRODUCT,
16
+ PATENT,
17
+ ])[0] as LinkSide
18
+
19
+ const VIEW: RecordPermissions = {
20
+ level: 'view', canComment: true, canSuggest: true, canEdit: false, canAccept: false,
21
+ canRestore: false, canTag: false, canLink: false, canShare: false, canDelete: false,
22
+ canTransfer: false,
23
+ }
24
+ const EDIT: RecordPermissions = { ...VIEW, level: 'edit', canEdit: true, canAccept: true,
25
+ canRestore: true, canTag: true, canLink: true, canShare: true }
26
+
27
+ const api: LinkPanelApi = {
28
+ listTypes: async () => ({ results: [PRODUCT, PATENT] }),
29
+ listEdgesFor: async () => [{ id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1' }] as RelationshipEdgeShape[],
30
+ listRelated: async () => ({ results: [{ id: 'X1', name: 'A patent', data: {} }], next: null }),
31
+ searchRecords: async () => ({ results: [] as EntityRecordShape[] }),
32
+ createEdge: async () => undefined,
33
+ deleteEdge: async () => undefined,
34
+ }
35
+
36
+ function renderPanel(record: EntityRecordShape) {
37
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
38
+ return render(
39
+ <QueryClientProvider client={qc}>
40
+ <LinkPanel record={record} side={side} api={api} />
41
+ </QueryClientProvider>,
42
+ )
43
+ }
44
+
45
+ describe('permissions', () => {
46
+ it('reads the object, and falls back when a tenant sends none', () => {
47
+ expect(permissionsOf({ permissions: EDIT })).toEqual(EDIT)
48
+ expect(permissionsOf({})).toBeNull()
49
+ expect(can({ permissions: VIEW }, 'canEdit', true)).toBe(false)
50
+ expect(can({}, 'canEdit', true)).toBe(true)
51
+ expect(can({}, 'canEdit')).toBe(false)
52
+ })
53
+
54
+ it('offers exactly the two enforced share levels', () => {
55
+ expect(SHARE_LEVELS.map((l) => [l.value, l.label])).toEqual([
56
+ ['viewer', 'Can view'],
57
+ ['editor', 'Can edit'],
58
+ ])
59
+ })
60
+
61
+ it('hides Add and Unlink from someone who can only view the record', async () => {
62
+ renderPanel({ id: 'P1', name: 'A product', data: {}, permissions: VIEW })
63
+ expect(await screen.findByText('A patent')).toBeInTheDocument()
64
+ expect(screen.queryByRole('button', { name: /add/i })).toBeNull()
65
+ expect(screen.queryByRole('button', { name: /unlink/i })).toBeNull()
66
+ })
67
+
68
+ it('shows them to someone who can edit it', async () => {
69
+ renderPanel({ id: 'P1', name: 'A product', data: {}, permissions: EDIT })
70
+ expect(await screen.findByText('A patent')).toBeInTheDocument()
71
+ expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument()
72
+ expect(screen.getByRole('button', { name: /unlink/i })).toBeInTheDocument()
73
+ })
74
+ })
@@ -17,5 +17,6 @@ export * from './list-query';
17
17
  export * from './publication';
18
18
  export * from './record-validation';
19
19
  export * from './system-attrs';
20
+ export * from './permissions';
20
21
  export { LinkPanel, linkQueryKey } from './LinkPanel';
21
22
  export type { LinkPanelApi, LinkPanelProps, LinkPanelPage } from './LinkPanel';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * WHAT THE CALLER MAY DO TO A RECORD, as the tenant says it (bd startsim-768w.71).
3
+ *
4
+ * Every record a foundry tenant serves carries a `permissions` object, computed
5
+ * by the same code that refuses the writes (tenant-starter
6
+ * `apps/schema_registry/rights.py`; the model in plain words is
7
+ * `tenant-starter/PERMISSIONS.md`). A screen shows or hides each control from
8
+ * this ONE answer, and never re-derives it from the workspace role: a Viewer
9
+ * with a "Can edit" share edits that record, and a Member with "Can view" on a
10
+ * space does not edit what is in it.
11
+ *
12
+ * An older tenant that does not send the object yields `null`, and `can()`
13
+ * falls back to whatever the caller says the old behaviour was, so a tenant
14
+ * that has not rolled yet keeps its controls.
15
+ */
16
+ import type { EntityRecordShape } from './types';
17
+
18
+ export type AccessLevel = 'view' | 'edit' | 'full';
19
+
20
+ export interface RecordPermissions {
21
+ level: AccessLevel;
22
+ canComment: boolean;
23
+ canSuggest: boolean;
24
+ canEdit: boolean;
25
+ canAccept: boolean;
26
+ canRestore: boolean;
27
+ canTag: boolean;
28
+ canLink: boolean;
29
+ canShare: boolean;
30
+ canDelete: boolean;
31
+ canTransfer: boolean;
32
+ }
33
+
34
+ export type Permission = Exclude<keyof RecordPermissions, 'level'>;
35
+
36
+ /** The record's permissions object, or `null` when the tenant sent none. */
37
+ export function permissionsOf(
38
+ record: Pick<EntityRecordShape, 'permissions'> | null | undefined,
39
+ ): RecordPermissions | null {
40
+ const p = record?.permissions;
41
+ return p && typeof p === 'object' ? (p as RecordPermissions) : null;
42
+ }
43
+
44
+ /** May the caller do `what` to `record`? `fallback` answers for a tenant that
45
+ * sends no permissions object yet. */
46
+ export function can(
47
+ record: Pick<EntityRecordShape, 'permissions'> | null | undefined,
48
+ what: Permission,
49
+ fallback = false,
50
+ ): boolean {
51
+ const p = permissionsOf(record);
52
+ if (!p) return fallback;
53
+ return p[what] === true;
54
+ }
55
+
56
+ /**
57
+ * The share levels, exactly the ones the tenant enforces (Quinn 2026-10-08:
58
+ * "Can view" and "Can edit", nothing else). `value` is what a grant stores.
59
+ */
60
+ export const SHARE_LEVELS = [
61
+ { value: 'viewer', label: 'Can view', description: 'Read, suggest and comment' },
62
+ { value: 'editor', label: 'Can edit', description: 'Change, accept suggestions and share' },
63
+ ] as const;
64
+
65
+ export type ShareLevel = (typeof SHARE_LEVELS)[number]['value'];
@@ -61,6 +61,9 @@ export interface EntityRecordShape {
61
61
  name?: string | null;
62
62
  externalId?: string | null;
63
63
  data?: Record<string, unknown>;
64
+ /** What the caller may do to this record (bd startsim-768w.71). Absent on a
65
+ * tenant that has not rolled the permission model; see ./permissions. */
66
+ permissions?: import('./permissions').RecordPermissions | null;
64
67
  }
65
68
 
66
69
  /** A declared relationship: a fixed source type -> target type edge. */