@startsimpli/ui 0.4.104 → 0.4.106

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.
Files changed (30) hide show
  1. package/package.json +3 -2
  2. package/src/components/navigation/__tests__/breadcrumbs.test.tsx +16 -0
  3. package/src/components/navigation/breadcrumbs.tsx +39 -0
  4. package/src/components/navigation/index.ts +1 -0
  5. package/src/components/unified-table/UnifiedTable.tsx +30 -2
  6. package/src/components/unified-table/__tests__/integration/search-and-empty-state.test.tsx +210 -0
  7. package/src/components/unified-table/components/DataTableCore/index.tsx +6 -2
  8. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +53 -3
  9. package/src/components/unified-table/utils/export.ts +5 -37
  10. package/src/foundry/LinkPanel.tsx +394 -0
  11. package/src/foundry/__tests__/LinkPanel.test.tsx +139 -0
  12. package/src/foundry/__tests__/links.test.ts +251 -0
  13. package/src/foundry/__tests__/list-query.test.ts +200 -0
  14. package/src/foundry/__tests__/publication.test.ts +227 -0
  15. package/src/foundry/__tests__/record-validation.test.ts +132 -0
  16. package/src/foundry/__tests__/types.test.ts +53 -0
  17. package/src/foundry/index.ts +21 -0
  18. package/src/foundry/links.ts +227 -0
  19. package/src/foundry/list-query.ts +241 -0
  20. package/src/foundry/publication.ts +260 -0
  21. package/src/foundry/record-validation.ts +109 -0
  22. package/src/foundry/system-attrs.ts +51 -0
  23. package/src/foundry/types.ts +137 -0
  24. package/src/hooks/index.ts +1 -0
  25. package/src/hooks/use-now.ts +46 -0
  26. package/src/index.ts +1 -0
  27. package/src/utils/__tests__/csv.test.ts +67 -0
  28. package/src/utils/__tests__/download-csv.test.ts +40 -0
  29. package/src/utils/csv.ts +77 -0
  30. package/src/utils/index.ts +9 -0
@@ -0,0 +1,394 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * THE LINKING UI — one component, both directions.
5
+ *
6
+ * "These records are associated with that one" is the interaction a generic
7
+ * record form cannot express: an association is a `core_relationship` row, not a
8
+ * field on `Entity.data`, so no arrangement of text inputs writes one. What this
9
+ * adds over a read-only "Related" list is the two verbs that make it a product:
10
+ * LINK and UNLINK.
11
+ *
12
+ * ONE PANEL, PARAMETERISED BY A LinkSide (see ./links), so the reverse direction
13
+ * cannot drift from the forward one — it is not a second implementation, it is
14
+ * the same one with `role` flipped.
15
+ *
16
+ * READS ARE SERVER-FILTERED, both of them:
17
+ * - `?entity=&rel_type=` the edges (carry the id UNLINK deletes)
18
+ * - `?related_to=&related_via=&related_direction=` the far records (carry the names)
19
+ * Two bounded calls, joined by id. Neither grows with the size of the org.
20
+ *
21
+ * THE TENANT API IS INJECTED, not imported. Every tenant app owns its own
22
+ * `foundry-api.ts` bound to its own configured client; a shared component that
23
+ * imported one would only work in the app it was lifted from. The `api` prop is
24
+ * the six calls this panel makes and nothing else.
25
+ *
26
+ * Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-16d5.
27
+ */
28
+ import { useMemo, useState } from 'react';
29
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
30
+ import { Plus, X, Search, Link2 } from 'lucide-react';
31
+
32
+ import { Button } from '../components/ui/button';
33
+ import { Input } from '../components/ui/input';
34
+ import { notify } from '../components/toast/use-notify';
35
+ import { useNow } from '../hooks/use-now';
36
+ import {
37
+ edgeEndpoints,
38
+ edgesForSide,
39
+ farDirection,
40
+ farIdOf,
41
+ joinLinkRows,
42
+ linkCandidates,
43
+ recordLabel,
44
+ type LinkSide,
45
+ } from './links';
46
+ import {
47
+ readRecordValue,
48
+ type AttributeShape,
49
+ type EntityId,
50
+ type EntityRecordShape,
51
+ type EntityTypeShape,
52
+ type RelationshipEdgeShape,
53
+ type SchemaId,
54
+ } from './types';
55
+
56
+ /** One page of a DRF list response, as much of one as this panel reads. */
57
+ export interface LinkPanelPage<T> {
58
+ results: T[];
59
+ next?: string | null;
60
+ }
61
+
62
+ /**
63
+ * The tenant calls this panel makes. Six, and none of them mentions a client
64
+ * type — an app supplies its own `foundry-api.ts` functions here.
65
+ */
66
+ export interface LinkPanelApi {
67
+ /** Every declared type, for the far type's label. */
68
+ listTypes(): Promise<LinkPanelPage<EntityTypeShape>>;
69
+ /** Every edge on this record with one of these rel types. */
70
+ listEdgesFor(recordId: EntityId, relTypes: string[]): Promise<RelationshipEdgeShape[]>;
71
+ /** One page of the records on the far end of a side. */
72
+ listRelated(args: {
73
+ type: string;
74
+ relatedTo: EntityId;
75
+ via: string;
76
+ direction: 'in' | 'out';
77
+ page: number;
78
+ }): Promise<LinkPanelPage<EntityRecordShape>>;
79
+ /** A server-side `?search=` over one type, for the picker. */
80
+ searchRecords(typeKey: string, search: string): Promise<LinkPanelPage<EntityRecordShape>>;
81
+ createEdge(input: { relType: string; source: EntityId; target: EntityId }): Promise<unknown>;
82
+ deleteEdge(edgeId: SchemaId): Promise<unknown>;
83
+ }
84
+
85
+ /** The queryKey both halves of a side share, so one mutation invalidates both. */
86
+ export function linkQueryKey(recordId: EntityId, side: LinkSide) {
87
+ return ['links', String(recordId), side.relType, side.role] as const;
88
+ }
89
+
90
+ /** The date attribute a far record's supporting line reads. First declared one,
91
+ * unless the caller knows better. */
92
+ function firstDateAttribute(type: EntityTypeShape | undefined | null): AttributeShape | null {
93
+ return (type?.attributes ?? []).find((a) => a.dataType === 'date') ?? null;
94
+ }
95
+
96
+ /** Every far record for a side, across pages (capped) — a list of associations
97
+ * must not stop at page one. */
98
+ async function listAllRelated(
99
+ api: LinkPanelApi,
100
+ side: LinkSide,
101
+ recordId: EntityId,
102
+ maxPages = 10,
103
+ ): Promise<EntityRecordShape[]> {
104
+ const all: EntityRecordShape[] = [];
105
+ for (let page = 1; page <= maxPages; page++) {
106
+ const res = await api.listRelated({
107
+ type: side.farTypeKey,
108
+ relatedTo: recordId,
109
+ via: side.relType,
110
+ direction: farDirection(side),
111
+ page,
112
+ });
113
+ all.push(...res.results);
114
+ if (!res.next) break;
115
+ }
116
+ return all;
117
+ }
118
+
119
+ /**
120
+ * A far record's supporting line. Deliberately SCHEMA-DRIVEN: `external_id` is
121
+ * the natural key for a tenant type, and the date comes from whatever `date`
122
+ * attribute the far type declares.
123
+ *
124
+ * A PAST DATE IS SHOWN, NOT HIDDEN. Suppressing the row would quietly make a
125
+ * record's association list disagree with its own links, and whether a date has
126
+ * passed is a different question from whether the association still holds.
127
+ */
128
+ function FarRecordMeta({
129
+ record,
130
+ farType,
131
+ dateAttributeFor,
132
+ }: {
133
+ record: EntityRecordShape;
134
+ farType?: EntityTypeShape;
135
+ dateAttributeFor?: (type: EntityTypeShape) => AttributeShape | null;
136
+ }) {
137
+ const now = useNow();
138
+
139
+ const dateAttr = farType
140
+ ? (dateAttributeFor ?? firstDateAttribute)(farType)
141
+ : null;
142
+ const raw = dateAttr ? readRecordValue(record.data, dateAttr.name) : null;
143
+ const iso = raw == null ? '' : String(raw);
144
+ const parsed = iso ? new Date(iso) : null;
145
+ const valid = parsed && !Number.isNaN(parsed.getTime());
146
+ const expired = valid && now > 0 ? parsed.getTime() < now : false;
147
+
148
+ if (!record.externalId && !valid) return null;
149
+ return (
150
+ <span className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
151
+ {record.externalId ? (
152
+ <span className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
153
+ {record.externalId}
154
+ </span>
155
+ ) : null}
156
+ {valid ? (
157
+ <span className={expired ? 'font-medium text-red-600' : ''}>
158
+ {dateAttr?.name.replace(/_/g, ' ')} {parsed!.toLocaleDateString()}
159
+ {expired ? ' — expired' : ''}
160
+ </span>
161
+ ) : null}
162
+ </span>
163
+ );
164
+ }
165
+
166
+ export interface LinkPanelProps {
167
+ /** The record whose links these are. */
168
+ record: EntityRecordShape;
169
+ side: LinkSide;
170
+ /** The tenant calls. See LinkPanelApi. */
171
+ api: LinkPanelApi;
172
+ /** Heading override; defaults to the far type's label. */
173
+ title?: string;
174
+ /** One line saying what this association MEANS, shown under the heading. */
175
+ description?: string;
176
+ /** Shown when nothing is linked. The default deliberately does not read as a
177
+ * statement that nothing exists. */
178
+ emptyMessage?: string;
179
+ /** Which attribute the supporting line dates a far record by. Defaults to the
180
+ * first `date` attribute the far type declares. */
181
+ dateAttributeFor?: (type: EntityTypeShape) => AttributeShape | null;
182
+ }
183
+
184
+ export function LinkPanel({
185
+ record,
186
+ side,
187
+ api,
188
+ title,
189
+ description,
190
+ emptyMessage = 'Nothing linked yet. An empty list is not a statement that none exists.',
191
+ dateAttributeFor,
192
+ }: LinkPanelProps) {
193
+ const qc = useQueryClient();
194
+ const [picking, setPicking] = useState(false);
195
+ const [search, setSearch] = useState('');
196
+ const [busy, setBusy] = useState(false);
197
+
198
+ const typesQuery = useQuery({ queryKey: ['schema-types'], queryFn: () => api.listTypes() });
199
+ const farType = typesQuery.data?.results.find((t) => t.key === side.farTypeKey);
200
+
201
+ const edgesQuery = useQuery({
202
+ queryKey: [...linkQueryKey(record.id, side), 'edges'],
203
+ queryFn: () => api.listEdgesFor(record.id, [side.relType]),
204
+ });
205
+ const farQuery = useQuery({
206
+ queryKey: [...linkQueryKey(record.id, side), 'far'],
207
+ queryFn: () => listAllRelated(api, side, record.id),
208
+ });
209
+
210
+ const rows = useMemo(() => {
211
+ const edges = edgesForSide(edgesQuery.data ?? [], side, record.id);
212
+ return joinLinkRows(edges, side, farQuery.data ?? []);
213
+ }, [edgesQuery.data, farQuery.data, side, record.id]);
214
+
215
+ const linkedIds = useMemo(() => rows.map((r) => farIdOf(r.edge, side)), [rows, side]);
216
+
217
+ // The picker's pool: a SERVER-side ?search= over the far type, so a corpus of
218
+ // thousands never lands in the browser to be filtered here.
219
+ const poolQuery = useQuery({
220
+ queryKey: ['link-candidates', side.farTypeKey, search],
221
+ queryFn: () => api.searchRecords(side.farTypeKey, search),
222
+ enabled: picking,
223
+ });
224
+ const candidates = useMemo(
225
+ () => linkCandidates(poolQuery.data?.results ?? [], linkedIds, record.id),
226
+ [poolQuery.data, linkedIds, record.id],
227
+ );
228
+
229
+ async function refresh() {
230
+ await qc.invalidateQueries({ queryKey: linkQueryKey(record.id, side) });
231
+ // The other end's panel and any table count are now stale too.
232
+ await qc.invalidateQueries({ queryKey: ['links'] });
233
+ await qc.invalidateQueries({ queryKey: ['link-counts'] });
234
+ }
235
+
236
+ async function link(far: EntityRecordShape) {
237
+ setBusy(true);
238
+ try {
239
+ // The DECLARED direction, not the user's viewpoint — see ./links.
240
+ await api.createEdge(edgeEndpoints(side, record.id, far.id));
241
+ await refresh();
242
+ notify.success(`Linked ${recordLabel(far)}.`);
243
+ } catch (err) {
244
+ notify.error(err instanceof Error ? err.message : 'Could not create the link.');
245
+ } finally {
246
+ setBusy(false);
247
+ }
248
+ }
249
+
250
+ async function unlink(edgeId: SchemaId, label: string) {
251
+ setBusy(true);
252
+ try {
253
+ await api.deleteEdge(edgeId);
254
+ await refresh();
255
+ notify.success(`Unlinked ${label}.`);
256
+ } catch (err) {
257
+ notify.error(err instanceof Error ? err.message : 'Could not remove the link.');
258
+ } finally {
259
+ setBusy(false);
260
+ }
261
+ }
262
+
263
+ const loading = edgesQuery.isLoading || farQuery.isLoading;
264
+ const heading = title ?? farType?.label ?? side.farTypeKey;
265
+
266
+ return (
267
+ <section className="rounded-lg border">
268
+ <header className="flex items-start justify-between gap-3 border-b px-4 py-3">
269
+ <div className="min-w-0">
270
+ <h3 className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
271
+ <Link2 className="h-4 w-4 text-neutral-400" aria-hidden />
272
+ {heading}
273
+ <span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-normal text-neutral-600">
274
+ {loading ? '…' : rows.length}
275
+ </span>
276
+ </h3>
277
+ {description ? <p className="mt-0.5 text-xs text-neutral-500">{description}</p> : null}
278
+ </div>
279
+ <Button
280
+ size="sm"
281
+ variant={picking ? 'outline' : 'default'}
282
+ onClick={() => {
283
+ setPicking((p) => !p);
284
+ setSearch('');
285
+ }}
286
+ >
287
+ {picking ? (
288
+ 'Done'
289
+ ) : (
290
+ <>
291
+ <Plus className="mr-1 h-3.5 w-3.5" aria-hidden /> Add
292
+ </>
293
+ )}
294
+ </Button>
295
+ </header>
296
+
297
+ {picking ? (
298
+ <div className="border-b bg-neutral-50 px-4 py-3">
299
+ <div className="relative">
300
+ <Search
301
+ className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-neutral-400"
302
+ aria-hidden
303
+ />
304
+ <Input
305
+ autoFocus
306
+ value={search}
307
+ onChange={(e) => setSearch(e.target.value)}
308
+ placeholder={`Search ${heading.toLowerCase()}…`}
309
+ className="pl-8"
310
+ aria-label={`Search ${heading}`}
311
+ />
312
+ </div>
313
+ <ul className="mt-2 max-h-56 space-y-1 overflow-y-auto">
314
+ {poolQuery.isLoading ? (
315
+ <li className="px-1 py-2 text-sm text-neutral-500">Searching…</li>
316
+ ) : candidates.length === 0 ? (
317
+ <li className="px-1 py-2 text-sm text-neutral-500">
318
+ {(poolQuery.data?.results ?? []).length === 0
319
+ ? `No ${heading.toLowerCase()} match.`
320
+ : `Every match is already linked.`}
321
+ </li>
322
+ ) : (
323
+ candidates.map((c) => (
324
+ <li key={String(c.id)}>
325
+ <button
326
+ type="button"
327
+ disabled={busy}
328
+ onClick={() => link(c)}
329
+ className="flex w-full items-center justify-between gap-3 rounded border bg-white px-3 py-2 text-left text-sm hover:bg-neutral-50 disabled:opacity-50"
330
+ >
331
+ <span className="min-w-0">
332
+ <span className="block truncate">{recordLabel(c)}</span>
333
+ <FarRecordMeta
334
+ record={c}
335
+ farType={farType}
336
+ dateAttributeFor={dateAttributeFor}
337
+ />
338
+ </span>
339
+ <Plus className="h-4 w-4 shrink-0 text-neutral-400" aria-hidden />
340
+ </button>
341
+ </li>
342
+ ))
343
+ )}
344
+ </ul>
345
+ </div>
346
+ ) : null}
347
+
348
+ <div className="px-4 py-3">
349
+ {loading ? (
350
+ <p className="text-sm text-neutral-500">Loading…</p>
351
+ ) : rows.length === 0 ? (
352
+ <p className="text-sm text-neutral-500">{emptyMessage}</p>
353
+ ) : (
354
+ <ul className="space-y-1.5">
355
+ {rows.map(({ edge, record: far }) => {
356
+ const label = recordLabel(far, farIdOf(edge, side));
357
+ return (
358
+ <li
359
+ key={String(edge.id)}
360
+ className="flex items-center justify-between gap-3 rounded border px-3 py-2 text-sm"
361
+ >
362
+ <span className="min-w-0">
363
+ <span className="block truncate">{label}</span>
364
+ {far ? (
365
+ <FarRecordMeta
366
+ record={far}
367
+ farType={farType}
368
+ dateAttributeFor={dateAttributeFor}
369
+ />
370
+ ) : (
371
+ <span className="text-xs text-neutral-400">
372
+ linked, but not readable by you
373
+ </span>
374
+ )}
375
+ </span>
376
+ <button
377
+ type="button"
378
+ disabled={busy}
379
+ onClick={() => unlink(edge.id, label)}
380
+ aria-label={`Unlink ${label}`}
381
+ title={`Unlink ${label}`}
382
+ className="shrink-0 rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
383
+ >
384
+ <X className="h-4 w-4" aria-hidden />
385
+ </button>
386
+ </li>
387
+ );
388
+ })}
389
+ </ul>
390
+ )}
391
+ </div>
392
+ </section>
393
+ );
394
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The panel's whole reason to be shared is that the REVERSE direction is the
3
+ * same code as the forward one. So the test drives both ends of one declared
4
+ * relationship against one fake tenant and asserts the edge it writes.
5
+ */
6
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react'
7
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
+ import { LinkPanel, type LinkPanelApi } from '../LinkPanel'
9
+ import { linkSidesFor, type LinkSide } from '../links'
10
+ import type { EntityRecordShape, EntityTypeShape, RelationshipEdgeShape } from '../types'
11
+
12
+ const PRODUCT: EntityTypeShape = { id: 't1', key: 'product', label: 'Products', attributes: [] }
13
+ const PATENT: EntityTypeShape = {
14
+ id: 't2',
15
+ key: 'patent',
16
+ label: 'Patents',
17
+ attributes: [{ name: 'expiration', dataType: 'date' }],
18
+ }
19
+
20
+ const sides = linkSidesFor(PRODUCT, [{ key: 'marked_with', sourceType: 't1', targetType: 't2' }], [
21
+ PRODUCT,
22
+ PATENT,
23
+ ])
24
+ const forward = sides[0] as LinkSide
25
+ const reverse = linkSidesFor(PATENT, [{ key: 'marked_with', sourceType: 't1', targetType: 't2' }], [
26
+ PRODUCT,
27
+ PATENT,
28
+ ])[0] as LinkSide
29
+
30
+ function fakeApi(overrides: Partial<LinkPanelApi> = {}): LinkPanelApi & { created: unknown[]; deleted: unknown[] } {
31
+ const created: unknown[] = []
32
+ const deleted: unknown[] = []
33
+ return {
34
+ created,
35
+ deleted,
36
+ listTypes: async () => ({ results: [PRODUCT, PATENT] }),
37
+ listEdgesFor: async () => [] as RelationshipEdgeShape[],
38
+ listRelated: async () => ({ results: [] as EntityRecordShape[], next: null }),
39
+ searchRecords: async () => ({ results: [] as EntityRecordShape[] }),
40
+ createEdge: async (input) => { created.push(input) },
41
+ deleteEdge: async (id) => { deleted.push(id) },
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ function renderPanel(side: LinkSide, api: LinkPanelApi, record: EntityRecordShape) {
47
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
48
+ return render(
49
+ <QueryClientProvider client={qc}>
50
+ <LinkPanel record={record} side={side} api={api} />
51
+ </QueryClientProvider>
52
+ )
53
+ }
54
+
55
+ describe('LinkPanel', () => {
56
+ it('heads the panel with the FAR type and lists the far records', async () => {
57
+ const api = fakeApi({
58
+ listEdgesFor: async () => [
59
+ { id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1' },
60
+ ],
61
+ listRelated: async () => ({
62
+ results: [{ id: 'X1', name: 'A patent', externalId: 'US123', data: {} }],
63
+ next: null,
64
+ }),
65
+ })
66
+ renderPanel(forward, api, { id: 'P1', name: 'A product', data: {} })
67
+
68
+ expect(await screen.findByText('Patents')).toBeInTheDocument()
69
+ expect(await screen.findByText('A patent')).toBeInTheDocument()
70
+ expect(screen.getByText('US123')).toBeInTheDocument()
71
+ })
72
+
73
+ it('writes the DECLARED direction from the forward side', async () => {
74
+ const api = fakeApi({
75
+ searchRecords: async () => ({ results: [{ id: 'X1', name: 'A patent', data: {} }] }),
76
+ })
77
+ renderPanel(forward, api, { id: 'P1', name: 'A product', data: {} })
78
+
79
+ fireEvent.click(await screen.findByRole('button', { name: /add/i }))
80
+ fireEvent.click(await screen.findByText('A patent'))
81
+
82
+ await waitFor(() => expect(api.created).toHaveLength(1))
83
+ expect(api.created[0]).toEqual({ relType: 'marked_with', source: 'P1', target: 'X1' })
84
+ })
85
+
86
+ it('writes the SAME direction from the reverse side — the one nobody demos', async () => {
87
+ const api = fakeApi({
88
+ searchRecords: async () => ({ results: [{ id: 'P1', name: 'A product', data: {} }] }),
89
+ })
90
+ renderPanel(reverse, api, { id: 'X1', name: 'A patent', data: {} })
91
+
92
+ expect(await screen.findByText('Products')).toBeInTheDocument()
93
+ fireEvent.click(await screen.findByRole('button', { name: /add/i }))
94
+ fireEvent.click(await screen.findByText('A product'))
95
+
96
+ await waitFor(() => expect(api.created).toHaveLength(1))
97
+ // Not (thisRecord, thatRecord) — the def is product -> patent either way.
98
+ expect(api.created[0]).toEqual({ relType: 'marked_with', source: 'P1', target: 'X1' })
99
+ })
100
+
101
+ it('unlinks by the EDGE id, which only the edge read carries', async () => {
102
+ const api = fakeApi({
103
+ listEdgesFor: async () => [
104
+ { id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1' },
105
+ ],
106
+ listRelated: async () => ({ results: [{ id: 'X1', name: 'A patent', data: {} }], next: null }),
107
+ })
108
+ renderPanel(forward, api, { id: 'P1', name: 'A product', data: {} })
109
+
110
+ fireEvent.click(await screen.findByRole('button', { name: 'Unlink A patent' }))
111
+ await waitFor(() => expect(api.deleted).toEqual(['E1']))
112
+ })
113
+
114
+ it('renders an edge whose far end this caller cannot read, rather than dropping it', async () => {
115
+ const api = fakeApi({
116
+ listEdgesFor: async () => [
117
+ { id: 'E1', relType: 'marked_with', source: 'P1', target: 'X-hidden' },
118
+ ],
119
+ listRelated: async () => ({ results: [], next: null }),
120
+ })
121
+ renderPanel(forward, api, { id: 'P1', name: 'A product', data: {} })
122
+
123
+ expect(await screen.findByText('linked, but not readable by you')).toBeInTheDocument()
124
+ })
125
+
126
+ it('never offers an already-linked record in the picker', async () => {
127
+ const api = fakeApi({
128
+ listEdgesFor: async () => [
129
+ { id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1' },
130
+ ],
131
+ listRelated: async () => ({ results: [{ id: 'X1', name: 'A patent', data: {} }], next: null }),
132
+ searchRecords: async () => ({ results: [{ id: 'X1', name: 'A patent', data: {} }] }),
133
+ })
134
+ renderPanel(forward, api, { id: 'P1', name: 'A product', data: {} })
135
+
136
+ fireEvent.click(await screen.findByRole('button', { name: /add/i }))
137
+ expect(await screen.findByText('Every match is already linked.')).toBeInTheDocument()
138
+ })
139
+ })