@startsimpli/ui 0.4.149 → 0.4.151
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.
|
|
3
|
+
"version": "0.4.151",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@codemirror/state": "^6.0.0",
|
|
57
57
|
"@codemirror/view": "^6.0.0",
|
|
58
58
|
"@startsimpli/api": "^0.5.66",
|
|
59
|
-
"@startsimpli/auth": "^0.4.
|
|
59
|
+
"@startsimpli/auth": "^0.4.40",
|
|
60
60
|
"@startsimpli/i18n": "^0.1.3"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
@@ -152,8 +152,8 @@
|
|
|
152
152
|
"tailwindcss": "^3.4.19",
|
|
153
153
|
"tsup": "^8.5.1",
|
|
154
154
|
"typescript": "^6.0.3",
|
|
155
|
-
"@startsimpli/
|
|
156
|
-
"@startsimpli/
|
|
155
|
+
"@startsimpli/realtime": "0.0.2",
|
|
156
|
+
"@startsimpli/i18n": "0.1.3"
|
|
157
157
|
},
|
|
158
158
|
"keywords": [
|
|
159
159
|
"ui",
|
|
@@ -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
|
+
})
|
package/src/foundry/index.ts
CHANGED
|
@@ -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'];
|
package/src/foundry/types.ts
CHANGED
|
@@ -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. */
|