@_linked/shape-ui 2.0.0 → 2.3.0

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 (207) hide show
  1. package/lib/esm/columns.d.ts +58 -0
  2. package/lib/esm/columns.js +173 -0
  3. package/lib/esm/columns.js.map +1 -0
  4. package/lib/esm/components/AddInstanceForms.d.ts +38 -0
  5. package/lib/esm/components/AddInstanceForms.js +143 -0
  6. package/lib/esm/components/AddInstanceForms.js.map +1 -0
  7. package/lib/esm/components/AddInstanceForms.module.css +12 -0
  8. package/lib/esm/components/BatchEditDrawer.d.ts +13 -0
  9. package/lib/esm/components/BatchEditDrawer.js +122 -0
  10. package/lib/esm/components/BatchEditDrawer.js.map +1 -0
  11. package/lib/esm/components/BatchEditDrawer.module.css +193 -0
  12. package/lib/esm/components/CustomMultiSelect.d.ts +26 -0
  13. package/lib/esm/components/CustomMultiSelect.js +344 -0
  14. package/lib/esm/components/CustomMultiSelect.js.map +1 -0
  15. package/lib/esm/components/CustomMultiSelect.module.css +291 -0
  16. package/lib/esm/components/DynamicFormField.d.ts +21 -0
  17. package/lib/esm/components/DynamicFormField.js +149 -0
  18. package/lib/esm/components/DynamicFormField.js.map +1 -0
  19. package/lib/esm/components/DynamicFormField.module.css +169 -0
  20. package/lib/esm/components/EditInstanceForms.d.ts +15 -0
  21. package/lib/esm/components/EditInstanceForms.js +118 -0
  22. package/lib/esm/components/EditInstanceForms.js.map +1 -0
  23. package/lib/esm/components/FilterBadge.d.ts +20 -0
  24. package/lib/esm/components/FilterBadge.js +26 -0
  25. package/lib/esm/components/FilterBadge.js.map +1 -0
  26. package/lib/esm/components/FilterBadge.module.css +25 -0
  27. package/lib/esm/components/FormField.d.ts +18 -0
  28. package/lib/esm/components/FormField.js +9 -0
  29. package/lib/esm/components/FormField.js.map +1 -0
  30. package/lib/esm/components/FormField.module.css +57 -0
  31. package/lib/esm/components/InstanceDeletionDialog.d.ts +17 -0
  32. package/lib/esm/components/InstanceDeletionDialog.js +83 -0
  33. package/lib/esm/components/InstanceDeletionDialog.js.map +1 -0
  34. package/lib/esm/components/InstanceDeletionDialog.module.css +59 -0
  35. package/lib/esm/components/InstanceOverview.d.ts +43 -0
  36. package/lib/esm/components/InstanceOverview.js +236 -0
  37. package/lib/esm/components/InstanceOverview.js.map +1 -0
  38. package/lib/esm/components/InstanceOverview.module.css +35 -0
  39. package/lib/esm/components/InstanceView.d.ts +29 -0
  40. package/lib/esm/components/InstanceView.js +97 -0
  41. package/lib/esm/components/InstanceView.js.map +1 -0
  42. package/lib/esm/components/InstanceView.module.css +157 -0
  43. package/lib/esm/components/NodeBadge.d.ts +25 -0
  44. package/lib/esm/components/NodeBadge.js +29 -0
  45. package/lib/esm/components/NodeBadge.js.map +1 -0
  46. package/lib/esm/components/NodeValuesEditor.d.ts +15 -0
  47. package/lib/esm/components/NodeValuesEditor.js +29 -0
  48. package/lib/esm/components/NodeValuesEditor.js.map +1 -0
  49. package/lib/esm/components/NodeValuesEditor.module.css +4 -0
  50. package/lib/esm/components/ReactTable.d.ts +59 -0
  51. package/lib/esm/components/ReactTable.js +378 -0
  52. package/lib/esm/components/ReactTable.js.map +1 -0
  53. package/lib/esm/components/ReactTable.module.css +950 -0
  54. package/lib/esm/components/TextfieldEditor.d.ts +1 -2
  55. package/lib/esm/components/TextfieldEditor.js +2 -2
  56. package/lib/esm/components/TextfieldEditor.js.map +1 -1
  57. package/lib/esm/components/forms.module.css +96 -0
  58. package/lib/esm/host.d.ts +196 -0
  59. package/lib/esm/host.js +25 -0
  60. package/lib/esm/host.js.map +1 -0
  61. package/lib/esm/hostContext.d.ts +34 -0
  62. package/lib/esm/hostContext.js +95 -0
  63. package/lib/esm/hostContext.js.map +1 -0
  64. package/lib/esm/index.d.ts +60 -10
  65. package/lib/esm/index.js +60 -14
  66. package/lib/esm/index.js.map +1 -1
  67. package/lib/esm/read.d.ts +48 -0
  68. package/lib/esm/read.js +96 -0
  69. package/lib/esm/read.js.map +1 -0
  70. package/lib/esm/search.d.ts +32 -0
  71. package/lib/esm/search.js +87 -0
  72. package/lib/esm/search.js.map +1 -0
  73. package/lib/esm/shape/contracts.d.ts +58 -0
  74. package/lib/esm/shape/contracts.js +15 -0
  75. package/lib/esm/shape/contracts.js.map +1 -0
  76. package/lib/esm/shape/naming.d.ts +30 -0
  77. package/lib/esm/shape/naming.js +52 -0
  78. package/lib/esm/shape/naming.js.map +1 -0
  79. package/lib/esm/shape/nodeDisplay.d.ts +36 -0
  80. package/lib/esm/shape/nodeDisplay.js +117 -0
  81. package/lib/esm/shape/nodeDisplay.js.map +1 -0
  82. package/lib/esm/shape/propertyTypes.d.ts +12 -0
  83. package/lib/esm/shape/propertyTypes.js +60 -0
  84. package/lib/esm/shape/propertyTypes.js.map +1 -0
  85. package/lib/esm/shape/propertyVisibility.d.ts +56 -0
  86. package/lib/esm/shape/propertyVisibility.js +112 -0
  87. package/lib/esm/shape/propertyVisibility.js.map +1 -0
  88. package/lib/esm/shape/shapeDependencies.d.ts +41 -0
  89. package/lib/esm/shape/shapeDependencies.js +141 -0
  90. package/lib/esm/shape/shapeDependencies.js.map +1 -0
  91. package/lib/esm/shape/tableFilters.d.ts +35 -0
  92. package/lib/esm/shape/tableFilters.js +171 -0
  93. package/lib/esm/shape/tableFilters.js.map +1 -0
  94. package/lib/esm/shape/useFormDependencies.d.ts +38 -0
  95. package/lib/esm/shape/useFormDependencies.js +178 -0
  96. package/lib/esm/shape/useFormDependencies.js.map +1 -0
  97. package/lib/esm/shape/validation.d.ts +15 -0
  98. package/lib/esm/shape/validation.js +106 -0
  99. package/lib/esm/shape/validation.js.map +1 -0
  100. package/lib/esm/tokens.css +25 -0
  101. package/lib/esm/types.d.ts +22 -0
  102. package/lib/esm/types.js +1 -0
  103. package/lib/esm/types.js.map +1 -0
  104. package/lib/esm/write.d.ts +51 -0
  105. package/lib/esm/write.js +99 -0
  106. package/lib/esm/write.js.map +1 -0
  107. package/package.json +44 -18
  108. package/src/backend.ts +1 -0
  109. package/src/columns.regression.test.ts +143 -0
  110. package/src/columns.test.ts +200 -0
  111. package/src/columns.ts +228 -0
  112. package/src/components/AddInstanceForms.module.css +12 -0
  113. package/src/components/AddInstanceForms.tsx +321 -0
  114. package/src/components/AvatarEditor.tsx +34 -0
  115. package/src/components/BatchEditDrawer.module.css +193 -0
  116. package/src/components/BatchEditDrawer.tsx +295 -0
  117. package/src/components/CheckboxEditor.module.css +6 -0
  118. package/src/components/CheckboxEditor.tsx +86 -0
  119. package/src/components/CustomMultiSelect.module.css +291 -0
  120. package/src/components/CustomMultiSelect.tsx +527 -0
  121. package/src/components/DateEditor.tsx +50 -0
  122. package/src/components/DynamicFormField.module.css +169 -0
  123. package/src/components/DynamicFormField.tsx +357 -0
  124. package/src/components/EditInstanceForms.tsx +260 -0
  125. package/src/components/FilterBadge.module.css +25 -0
  126. package/src/components/FilterBadge.tsx +70 -0
  127. package/src/components/FormField.module.css +57 -0
  128. package/src/components/FormField.tsx +59 -0
  129. package/src/components/ImageValueEditor.module.css +3 -0
  130. package/src/components/ImageValueEditor.tsx +82 -0
  131. package/src/components/InstanceDeletionDialog.module.css +59 -0
  132. package/src/components/InstanceDeletionDialog.tsx +175 -0
  133. package/src/components/InstanceOverview.module.css +35 -0
  134. package/src/components/InstanceOverview.tsx +385 -0
  135. package/src/components/InstanceView.module.css +157 -0
  136. package/src/components/InstanceView.tsx +212 -0
  137. package/src/components/NodeBadge.tsx +60 -0
  138. package/src/components/NodeValuesEditor.module.css +4 -0
  139. package/src/components/NodeValuesEditor.tsx +64 -0
  140. package/src/components/RadioButtonEditor.module.css +22 -0
  141. package/src/components/RadioButtonEditor.tsx +96 -0
  142. package/src/components/ReactTable.module.css +950 -0
  143. package/src/components/ReactTable.tsx +1013 -0
  144. package/src/components/SelectEditor.tsx +85 -0
  145. package/src/components/ShapeTable.module.css +394 -0
  146. package/src/components/SwitchEditor.tsx +60 -0
  147. package/src/components/TextareaEditor.tsx +55 -0
  148. package/src/components/TextfieldEditor.tsx +67 -0
  149. package/src/components/ToggleEditor.tsx +63 -0
  150. package/src/components/forms.module.css +96 -0
  151. package/src/host.ts +226 -0
  152. package/src/hostContext.tsx +114 -0
  153. package/src/index.ts +93 -0
  154. package/src/package.ts +21 -0
  155. package/src/read.ts +148 -0
  156. package/src/search.test.ts +174 -0
  157. package/src/search.ts +108 -0
  158. package/src/shape/contracts.ts +66 -0
  159. package/src/shape/naming.ts +52 -0
  160. package/src/shape/nodeDisplay.ts +114 -0
  161. package/src/shape/propertyTypes.ts +86 -0
  162. package/src/shape/propertyVisibility.ts +180 -0
  163. package/src/shape/shapeDependencies.ts +183 -0
  164. package/src/shape/tableFilters.ts +223 -0
  165. package/src/shape/types.ts +1158 -0
  166. package/src/shape/useFormDependencies.ts +257 -0
  167. package/src/shape/validation.ts +121 -0
  168. package/src/tokens.css +25 -0
  169. package/src/types.ts +20 -0
  170. package/src/utils/helper.ts +35 -0
  171. package/src/write.test.ts +174 -0
  172. package/src/write.ts +114 -0
  173. package/.changeset/README.md +0 -20
  174. package/.changeset/config.json +0 -14
  175. package/.github/workflows/changeset-check.yml +0 -44
  176. package/.github/workflows/ci.yml +0 -43
  177. package/.github/workflows/publish.yml +0 -158
  178. package/CHANGELOG.md +0 -30
  179. package/lib/esm/components/AvatarEditor.d.ts +0 -20
  180. package/lib/esm/components/AvatarEditor.js +0 -7
  181. package/lib/esm/components/AvatarEditor.js.map +0 -1
  182. package/lib/esm/components/CheckboxEditor.d.ts +0 -21
  183. package/lib/esm/components/CheckboxEditor.js +0 -44
  184. package/lib/esm/components/CheckboxEditor.js.map +0 -1
  185. package/lib/esm/components/DateEditor.d.ts +0 -20
  186. package/lib/esm/components/DateEditor.js +0 -22
  187. package/lib/esm/components/DateEditor.js.map +0 -1
  188. package/lib/esm/components/RadioButtonEditor.d.ts +0 -23
  189. package/lib/esm/components/RadioButtonEditor.js +0 -41
  190. package/lib/esm/components/RadioButtonEditor.js.map +0 -1
  191. package/lib/esm/components/SelectEditor.d.ts +0 -21
  192. package/lib/esm/components/SelectEditor.js +0 -30
  193. package/lib/esm/components/SelectEditor.js.map +0 -1
  194. package/lib/esm/components/SwitchEditor.d.ts +0 -21
  195. package/lib/esm/components/SwitchEditor.js +0 -23
  196. package/lib/esm/components/SwitchEditor.js.map +0 -1
  197. package/lib/esm/components/TextareaEditor.d.ts +0 -23
  198. package/lib/esm/components/TextareaEditor.js +0 -15
  199. package/lib/esm/components/TextareaEditor.js.map +0 -1
  200. package/lib/esm/components/ToggleEditor.d.ts +0 -22
  201. package/lib/esm/components/ToggleEditor.js +0 -23
  202. package/lib/esm/components/ToggleEditor.js.map +0 -1
  203. package/lib/esm/ontologies/lincd-ui.d.ts +0 -6
  204. package/lib/esm/ontologies/lincd-ui.js +0 -18
  205. package/lib/esm/ontologies/lincd-ui.js.map +0 -1
  206. package/tsconfig-cjs.json +0 -8
  207. package/tsconfig-esm.json +0 -9
package/src/read.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Reading instances of a shape, through the Linked Query DSL.
3
+ *
4
+ * The counterpart of `write.ts`, and the piece a consumer previously had to write themselves:
5
+ * the components took `instances` as a prop, and the only exported read helper was
6
+ * picker-shaped (label only). So a host got the rendering for free and reimplemented the
7
+ * reading — which is how two ideas of "what a row is" appear in one codebase.
8
+ *
9
+ * Names no project. `SelectBuilder.from(shapeIri)` is routed by `LinkedStorage` exactly as the
10
+ * write is: `AppDataRouter` inside a studio, the app's own default dataset in a standalone or
11
+ * Capacitor build. Read and write route as a pair, or a table ends up looking at a different
12
+ * dataset than the form saves into.
13
+ *
14
+ * The projection comes from `columnsFor`, the same rule the table's columns use, so a row can
15
+ * never carry a property the table does not show or miss one it does.
16
+ */
17
+
18
+ import {SelectBuilder} from '@_linked/core/queries/QueryBuilder';
19
+ import type {
20
+ NodeShapeWire,
21
+ PropertyShapeWire,
22
+ } from '@_linked/core/shapes/nodeShapeWire';
23
+ import {columnsFor, type DisplayContext} from './columns.js';
24
+
25
+ /**
26
+ * Key each row by the property LABEL, whatever the query returned.
27
+ *
28
+ * The projection asks for `instance[property.label]`, but results come back keyed by the
29
+ * property-shape IRI's local part. For the framework's own IRI scheme —
30
+ * `.../shape/{pkg}/{Shape}/{prop}` — those are the same string, so the mismatch is invisible.
31
+ * For a shape authored in a hash namespace they are not: `…/vocab#Contact/name` has the local
32
+ * part `Contact/name`, and every value lands under a key no caller looks up. The table then
33
+ * renders empty columns with no error, which is the worst way for this to fail.
34
+ *
35
+ * Matching on the last path segment is what makes both schemes agree. Anything already keyed
36
+ * by its label is left alone, so this costs nothing in the common case.
37
+ */
38
+ function keyByLabel(
39
+ rows: Record<string, unknown>[],
40
+ properties: PropertyShapeWire[],
41
+ ): Record<string, unknown>[] {
42
+ const labels = properties.map((p) => p.label).filter(Boolean) as string[];
43
+ if (labels.length === 0) return rows;
44
+
45
+ const lastSegment = (key: string) => key.split(/[#/]/).pop() ?? key;
46
+
47
+ return rows.map((row) => {
48
+ const out: Record<string, unknown> = {};
49
+ // `id` is not a property; it names the row.
50
+ if ('id' in row) out.id = row.id;
51
+ for (const label of labels) {
52
+ if (label in row) {
53
+ out[label] = row[label];
54
+ continue;
55
+ }
56
+ const match = Object.keys(row).find(
57
+ (key) => key !== 'id' && lastSegment(key) === label,
58
+ );
59
+ if (match) out[label] = row[match];
60
+ }
61
+ return out;
62
+ });
63
+ }
64
+
65
+ export interface InstanceQuery {
66
+ /** Which properties to read. Defaults to the columns for `context`. */
67
+ properties?: PropertyShapeWire[];
68
+ context?: DisplayContext;
69
+ limit?: number;
70
+ offset?: number;
71
+ }
72
+
73
+ export interface InstanceRows {
74
+ rows: Record<string, unknown>[];
75
+ /** The properties actually read, in the order they were asked for. */
76
+ properties: PropertyShapeWire[];
77
+ }
78
+
79
+ /**
80
+ * Instances of one shape.
81
+ *
82
+ * The shape must already be registered — call `loadShapeCatalog` first. That is deliberate
83
+ * rather than convenient: registering inside the query would hide a catalog read behind
84
+ * every table refresh.
85
+ */
86
+ export async function selectInstances(
87
+ shape: NodeShapeWire,
88
+ query: InstanceQuery = {},
89
+ ): Promise<InstanceRows> {
90
+ const properties =
91
+ query.properties ??
92
+ columnsFor(shape, query.context ?? 'table').map((column) => column.property);
93
+
94
+ if (properties.length === 0) {
95
+ // Nothing showable. An empty result is the honest answer; a query with no projection
96
+ // would either error or return bare subjects the caller cannot render.
97
+ return {rows: [], properties};
98
+ }
99
+
100
+ // `any` on the proxy parameter is the established form for a runtime shape: the shape
101
+ // is not a compiled class, so there is no generated type for the builder to infer from.
102
+ // The property labels are the contract, and they come from the shape itself.
103
+ let builder = SelectBuilder.from(shape.id).select((instance: any) =>
104
+ properties.map((property) => instance[property.label]),
105
+ ) as unknown as {
106
+ limit(n: number): unknown;
107
+ offset(n: number): unknown;
108
+ exec(): Promise<Record<string, unknown>[]>;
109
+ };
110
+
111
+ if (query.limit != null) builder = builder.limit(query.limit) as typeof builder;
112
+ if (query.offset != null) builder = builder.offset(query.offset) as typeof builder;
113
+
114
+ const rows = await builder.exec();
115
+ return {rows: keyByLabel(rows ?? [], properties), properties};
116
+ }
117
+
118
+ /**
119
+ * One instance by id.
120
+ *
121
+ * Same query as {@link selectInstances}, narrowed to a subject — so a detail view and a
122
+ * table cannot disagree about what a property means or how it is read.
123
+ */
124
+ export async function selectInstance(
125
+ shape: NodeShapeWire,
126
+ instanceId: string,
127
+ query: InstanceQuery = {},
128
+ ): Promise<{row: Record<string, unknown> | null; properties: PropertyShapeWire[]}> {
129
+ const properties =
130
+ query.properties ??
131
+ (shape.propertyShapes ?? []).filter((p) => p.displayHidden !== true && !!p.label);
132
+
133
+ if (properties.length === 0) return {row: null, properties};
134
+
135
+ const rows = (await (
136
+ SelectBuilder.from(shape.id).select((instance: any) =>
137
+ properties.map((property) => instance[property.label]),
138
+ ) as unknown as {
139
+ where(fn: (i: any) => unknown): {
140
+ exec(): Promise<Record<string, unknown>[]>;
141
+ };
142
+ }
143
+ )
144
+ .where((instance: any) => instance.equals({id: instanceId}))
145
+ .exec()) as Record<string, unknown>[];
146
+
147
+ return {row: keyByLabel(rows ?? [], properties)[0] ?? null, properties};
148
+ }
@@ -0,0 +1,174 @@
1
+ import {beforeEach, describe, expect, jest, test} from '@jest/globals';
2
+ import type {NodeShapeWire} from '@_linked/core/shapes/nodeShapeWire';
3
+
4
+ /**
5
+ * The DSL default for relation-field search.
6
+ *
7
+ * Two things are worth pinning: it names **no project** (that is the portability claim),
8
+ * and it derives the label from `labelProperty()` — the same rule the table uses, so a row
9
+ * and a picker chip cannot disagree about what identifies an instance.
10
+ *
11
+ * The builder is mocked; what matters is the shape of the call, not the SPARQL, which
12
+ * core's own suite covers.
13
+ */
14
+
15
+ const fromMock = jest.fn();
16
+ const selectMock = jest.fn();
17
+ const whereMock = jest.fn();
18
+ const limitMock = jest.fn();
19
+ const offsetMock = jest.fn();
20
+ const execMock = jest.fn<() => Promise<Record<string, unknown>[]>>();
21
+
22
+ jest.unstable_mockModule('@_linked/core/queries/QueryBuilder', () => ({
23
+ SelectBuilder: {
24
+ from: (...args: unknown[]) => {
25
+ fromMock(...args);
26
+ const builder: Record<string, unknown> = {};
27
+ builder.select = (fn: (i: Record<string, unknown>) => unknown) => {
28
+ const read: string[] = [];
29
+ fn(new Proxy({}, {get: (_t, key) => (read.push(String(key)), key)}));
30
+ selectMock(read);
31
+ return builder;
32
+ };
33
+ builder.where = (fn: (i: unknown) => unknown) => {
34
+ fn(
35
+ new Proxy(
36
+ {},
37
+ {
38
+ get: () => ({
39
+ contains: (v: unknown) => (whereMock(v), true),
40
+ }),
41
+ },
42
+ ),
43
+ );
44
+ return builder;
45
+ };
46
+ builder.limit = (n: number) => (limitMock(n), builder);
47
+ builder.offset = (n: number) => (offsetMock(n), builder);
48
+ builder.exec = execMock;
49
+ return builder;
50
+ },
51
+ },
52
+ }));
53
+
54
+ const {searchInstancesWithDsl, narrowSuggestions} = await import('./search.js');
55
+
56
+ const base = 'https://example.org/';
57
+
58
+ const shape = (props: Record<string, unknown>[]): NodeShapeWire =>
59
+ ({
60
+ id: `${base}shape/Person`,
61
+ label: 'Person',
62
+ targetClass: {id: `${base}vocab#Person`},
63
+ propertyShapes: props,
64
+ }) as NodeShapeWire;
65
+
66
+ const prop = (label: string, extra: Record<string, unknown> = {}) => ({
67
+ id: `${base}shape/Person/${label}`,
68
+ label,
69
+ path: {id: `${base}vocab#${label}`},
70
+ ...extra,
71
+ });
72
+
73
+ const rows = (n: number, labelKey = 'name') =>
74
+ Array.from({length: n}, (_, i) => ({id: `urn:${i}`, [labelKey]: `Person ${i}`}));
75
+
76
+ beforeEach(() => {
77
+ jest.clearAllMocks();
78
+ execMock.mockResolvedValue(rows(3));
79
+ });
80
+
81
+ describe('searchInstancesWithDsl', () => {
82
+ test('queries by shape IRI and names no project', async () => {
83
+ await searchInstancesWithDsl(shape([prop('name')]));
84
+ expect(fromMock).toHaveBeenCalledWith(`${base}shape/Person`);
85
+ expect(fromMock.mock.calls[0]).toHaveLength(1);
86
+ });
87
+
88
+ test('projects the label property the table would use', async () => {
89
+ await searchInstancesWithDsl(
90
+ shape([prop('sku'), prop('name', {displayRank: 1})]),
91
+ );
92
+ expect(selectMock).toHaveBeenCalledWith(['name']);
93
+ });
94
+
95
+ test('a text query filters on the label', async () => {
96
+ await searchInstancesWithDsl(shape([prop('name')]), {query: 'ada'});
97
+ expect(whereMock).toHaveBeenCalledWith('ada');
98
+ });
99
+
100
+ test('an empty or whitespace query browses rather than filtering', async () => {
101
+ await searchInstancesWithDsl(shape([prop('name')]), {query: ' '});
102
+ expect(whereMock).not.toHaveBeenCalled();
103
+ });
104
+
105
+ test('asks for one more than the page, to answer hasMore without a COUNT', async () => {
106
+ execMock.mockResolvedValue(rows(21));
107
+ const result = await searchInstancesWithDsl(shape([prop('name')]), {limit: 20});
108
+ expect(limitMock).toHaveBeenCalledWith(21);
109
+ expect(result.results).toHaveLength(20);
110
+ expect(result.hasMore).toBe(true);
111
+ });
112
+
113
+ test('hasMore is false when the probe comes back short', async () => {
114
+ execMock.mockResolvedValue(rows(5));
115
+ const result = await searchInstancesWithDsl(shape([prop('name')]), {limit: 20});
116
+ expect(result.hasMore).toBe(false);
117
+ expect(result.results).toHaveLength(5);
118
+ });
119
+
120
+ test('offset is passed only when non-zero', async () => {
121
+ await searchInstancesWithDsl(shape([prop('name')]), {offset: 0});
122
+ expect(offsetMock).not.toHaveBeenCalled();
123
+ await searchInstancesWithDsl(shape([prop('name')]), {offset: 40});
124
+ expect(offsetMock).toHaveBeenCalledWith(40);
125
+ });
126
+
127
+ test('falls back to the IRI when a shape has no showable label', async () => {
128
+ // A modelling gap, not a crash: showing the id at least lets someone recognise the row.
129
+ execMock.mockResolvedValue([{id: 'urn:7'}]);
130
+ const result = await searchInstancesWithDsl(
131
+ shape([prop('author', {valueShape: {id: `${base}shape/Person`}})]),
132
+ );
133
+ expect(result.results[0]).toEqual({id: 'urn:7', label: 'urn:7'});
134
+ });
135
+
136
+ test('a text query on a shape with no label does not filter everything out', async () => {
137
+ execMock.mockResolvedValue([{id: 'urn:7'}]);
138
+ const result = await searchInstancesWithDsl(
139
+ shape([prop('author', {valueShape: {id: `${base}shape/Person`}})]),
140
+ {query: 'ada'},
141
+ );
142
+ expect(whereMock).not.toHaveBeenCalled();
143
+ expect(result.results).toHaveLength(1);
144
+ });
145
+
146
+ test('reads a label out of a node reference, not just a string', async () => {
147
+ execMock.mockResolvedValue([{id: 'urn:1', name: {id: 'urn:x', label: 'Ada'}}]);
148
+ const result = await searchInstancesWithDsl(shape([prop('name')]));
149
+ expect(result.results[0].label).toBe('Ada');
150
+ });
151
+
152
+ test('survives a query that returns nothing', async () => {
153
+ execMock.mockResolvedValue(undefined as never);
154
+ const result = await searchInstancesWithDsl(shape([prop('name')]));
155
+ expect(result).toEqual({results: [], hasMore: false});
156
+ });
157
+ });
158
+
159
+ describe('narrowSuggestions', () => {
160
+ const suggestions = [
161
+ {id: 'a', label: 'A'},
162
+ {id: 'b', label: 'B'},
163
+ ];
164
+
165
+ test('passes everything through when there is no narrowing', () => {
166
+ expect(narrowSuggestions(suggestions)).toEqual(suggestions);
167
+ expect(narrowSuggestions(suggestions, [])).toEqual(suggestions);
168
+ expect(narrowSuggestions(suggestions, null)).toEqual(suggestions);
169
+ });
170
+
171
+ test('keeps only the allowed ids', () => {
172
+ expect(narrowSuggestions(suggestions, ['b'])).toEqual([{id: 'b', label: 'B'}]);
173
+ });
174
+ });
package/src/search.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Searching for instances of a shape, through the Linked Query DSL.
3
+ *
4
+ * This is the **default** implementation the host gets for free. Without it,
5
+ * `searchInstances` was an optional host method, so a relation field rendered an empty
6
+ * picker until someone wrote a search by hand — which made "works in a standalone app"
7
+ * true of the table and false of every relation field.
8
+ *
9
+ * It names no project. `SelectBuilder.from(shapeIri)` is routed by `LinkedStorage` exactly
10
+ * like the table read: `AppDataRouter` inside Create Now, the app's own default dataset in
11
+ * a standalone build.
12
+ *
13
+ * The label comes from `labelProperty()` — the same rule the table uses for its label
14
+ * column, so a row and a picker chip cannot disagree about what identifies an instance.
15
+ * Create Now's `Project.searchInstances` RPC computed a label server-side with its own
16
+ * rule; two mechanisms for one question is how the `getShapeInstances` divergence started.
17
+ */
18
+
19
+ import {SelectBuilder} from '@_linked/core/queries/QueryBuilder';
20
+ import type {NodeShapeWire} from '@_linked/core/shapes/nodeShapeWire';
21
+ import {labelProperty} from './columns.js';
22
+ import type {InstanceSuggestion, SearchInstancesOptions} from './host.js';
23
+
24
+ /** Case-insensitive "contains" over the label, or all instances when the query is empty. */
25
+ export async function searchInstancesWithDsl(
26
+ shape: NodeShapeWire,
27
+ options: SearchInstancesOptions = {},
28
+ ): Promise<{results: InstanceSuggestion[]; hasMore: boolean}> {
29
+ const label = labelProperty(shape);
30
+ const limit = options.limit ?? 20;
31
+ const offset = options.offset ?? 0;
32
+
33
+ // Ask for one more than the caller wants. That is how `hasMore` is answered without a
34
+ // second COUNT query — cheaper, and it cannot disagree with the page it describes.
35
+ const probe = limit + 1;
36
+
37
+ let builder = SelectBuilder.from(shape.id).select((instance: any) =>
38
+ label ? [instance[label.label]] : [],
39
+ ) as unknown as {
40
+ where(fn: (i: any) => unknown): unknown;
41
+ limit(n: number): unknown;
42
+ offset(n: number): unknown;
43
+ exec(): Promise<Record<string, unknown>[]>;
44
+ };
45
+
46
+ const query = options.query?.trim();
47
+ if (query && label) {
48
+ // No label means nothing to match on, so a text query would filter everything out —
49
+ // better to return the unfiltered page than an empty one.
50
+ builder = builder.where((instance: any) =>
51
+ instance[label.label].contains(query),
52
+ ) as typeof builder;
53
+ }
54
+
55
+ builder = builder.limit(probe) as typeof builder;
56
+ if (offset) builder = builder.offset(offset) as typeof builder;
57
+
58
+ const rows = (await builder.exec()) ?? [];
59
+ const hasMore = rows.length > limit;
60
+ const page = hasMore ? rows.slice(0, limit) : rows;
61
+
62
+ return {
63
+ results: page.map((row) => toSuggestion(row, label?.label)),
64
+ hasMore,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Narrow a result set to specific ids.
70
+ *
71
+ * Applied after the query rather than inside it: `narrowedIds` comes from another field's
72
+ * value and is usually a handful of ids, so filtering in memory avoids building a VALUES
73
+ * clause for a case that rarely has many members. Revisit if that assumption breaks.
74
+ */
75
+ export function narrowSuggestions(
76
+ suggestions: InstanceSuggestion[],
77
+ narrowedIds?: string[] | null,
78
+ ): InstanceSuggestion[] {
79
+ if (!narrowedIds?.length) return suggestions;
80
+ const allowed = new Set(narrowedIds);
81
+ return suggestions.filter((s) => allowed.has(s.id));
82
+ }
83
+
84
+ function toSuggestion(
85
+ row: Record<string, unknown>,
86
+ labelKey?: string,
87
+ ): InstanceSuggestion {
88
+ const id = String(row.id ?? '');
89
+ const raw = labelKey ? row[labelKey] : undefined;
90
+ return {
91
+ id,
92
+ // Fall back to the IRI rather than an empty chip. A shape with no showable label is a
93
+ // modelling gap, and showing the id at least lets someone recognise the row.
94
+ label: displayValue(raw) ?? id,
95
+ };
96
+ }
97
+
98
+ function displayValue(value: unknown): string | undefined {
99
+ if (value == null) return undefined;
100
+ if (typeof value === 'string') return value || undefined;
101
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
102
+ if (Array.isArray(value)) return displayValue(value[0]);
103
+ if (typeof value === 'object') {
104
+ const candidate = value as {label?: unknown; id?: unknown};
105
+ return displayValue(candidate.label) ?? displayValue(candidate.id);
106
+ }
107
+ return undefined;
108
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The wire shapes these components speak.
3
+ *
4
+ * Declared here rather than imported from a host application's client package. They were
5
+ * imported from `create-now-js`, and because a `.d.ts` keeps its type imports, the emitted
6
+ * types carried that dependency forward — so anyone installing this package needed one
7
+ * product's client just to typecheck a table. That is precisely the coupling the package
8
+ * exists to avoid, and it survived the move because type-only imports are invisible at
9
+ * runtime and nothing complained.
10
+ *
11
+ * TypeScript is structural, so a host that already has its own version of these — under any
12
+ * name — passes them straight in with no adapter and no cast.
13
+ */
14
+
15
+ import type {PaginationState} from '@tanstack/react-table';
16
+
17
+ /** Which page of a shape's instances to read, in what order, filtered how. */
18
+ export interface ShapeInstancesQueryConfig extends PaginationState {
19
+ orderBy?: unknown;
20
+ filters: unknown[];
21
+ }
22
+
23
+ /** One instance marked for deletion. */
24
+ export interface InstanceDeletionTarget {
25
+ instanceId: string;
26
+ shapeIri: string;
27
+ }
28
+
29
+ /** Something pointing AT an instance that is about to be deleted. */
30
+ export interface InstanceDeletionInboundReference {
31
+ subjectId: string;
32
+ propertyIri: string;
33
+ }
34
+
35
+ /**
36
+ * What deleting would actually do.
37
+ *
38
+ * `impactHash` is the point of the two-step flow: the host re-checks it before deleting, so a
39
+ * preview the viewer read and a graph that changed underneath cannot be confirmed by mistake.
40
+ */
41
+ export interface InstanceDeletionPreview {
42
+ impactHash: string;
43
+ targets: Array<
44
+ InstanceDeletionTarget & {
45
+ inboundReferences: InstanceDeletionInboundReference[];
46
+ sourceDocuments: string[];
47
+ pendingRecreation: boolean;
48
+ }
49
+ >;
50
+ }
51
+
52
+ export interface InstanceDeletionPreviewInput {
53
+ projectId: string;
54
+ targets: InstanceDeletionTarget[];
55
+ }
56
+
57
+ export interface InstanceDeletionExecuteInput extends InstanceDeletionPreviewInput {
58
+ expectedImpactHash: string;
59
+ }
60
+
61
+ export interface InstanceDeletionResult {
62
+ deleted: InstanceDeletionTarget[];
63
+ /** Not deleted, because a source document would recreate them on the next ingestion. */
64
+ suppressed: Array<InstanceDeletionTarget & {sourceDocumentIds: string[]}>;
65
+ activityEventId?: string;
66
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Turning a shape IRI or a label into something a person can read.
3
+ *
4
+ * Pure string work over the Linked shape-IRI scheme (arch-02), with no knowledge of any
5
+ * application: the same IRI produces the same short form wherever it is rendered. These live
6
+ * here because a table header, a form label and a breadcrumb must not each have their own idea
7
+ * of how `ActionPlan` is written out.
8
+ */
9
+
10
+ /**
11
+ * Format a shape or property label for display.
12
+ *
13
+ * `actionPlan` → `Action Plan`, and `HTMLParser` → `HTML Parser` — the second rule exists
14
+ * because the naive lowercase-to-uppercase split turns an acronym into `H T M L Parser`.
15
+ * Already-spaced and lowercase-only strings pass through unharmed.
16
+ */
17
+ export function formatShapeLabel(str: string): string {
18
+ if (!str) return str;
19
+ return str
20
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
21
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
22
+ .replace(/^./, (c) => c.toUpperCase())
23
+ .trim();
24
+ }
25
+
26
+ /**
27
+ * The short form of a shape IRI: `{packageSlug}:{ShapeName}`.
28
+ *
29
+ * Current scheme (arch-02): `https://linked.cm/shape/{slug}/{ShapeName}`.
30
+ * Legacy scheme: `https://data.lincd.org/module/{slug}/shape/{name}` — still read, because
31
+ * shapes materialized under the old scheme are still in stores.
32
+ *
33
+ * Falls back to the last two path segments rather than throwing. A shape from neither scheme
34
+ * still needs *a* short form, and a wrong-looking label is far better than a page that fails
35
+ * to render because one IRI was unusual.
36
+ */
37
+ export function getShapeParamsFromUri(nodeShapeURI: string): string {
38
+ const linkedMatch = nodeShapeURI.match(/\/shape\/([^/]+)\/([^/]+)$/);
39
+ if (linkedMatch) return `${linkedMatch[1]}:${linkedMatch[2]}`;
40
+
41
+ const legacyMatch = nodeShapeURI.match(/module\/(.*?)\/shape\/([^/]+)$/);
42
+ if (legacyMatch) return `${legacyMatch[1]}:${legacyMatch[2]}`;
43
+
44
+ const parts = nodeShapeURI.split('/');
45
+ return `${parts[parts.length - 2]}:${parts[parts.length - 1]}`;
46
+ }
47
+
48
+ /** The display name from a short form: `irl:ActionPlan` → `Action Plan`. */
49
+ export function getShapeNameFromParams(str: string): string {
50
+ const lastWord = str.split(':').pop() ?? str;
51
+ return formatShapeLabel(lastWord.charAt(0).toUpperCase() + lastWord.slice(1));
52
+ }
@@ -0,0 +1,114 @@
1
+ import { Prefix } from '@_linked/core/utils/Prefix';
2
+
3
+ /**
4
+ * Centralized label resolution for any node/value in the CMS.
5
+ * Handles NamedNode objects, Literal values, and plain strings consistently.
6
+ * Never returns [object Object].
7
+ */
8
+ export function getNodeDisplay(value: any): string {
9
+ if (value == null) return '';
10
+
11
+ // Primitives: string, number, boolean
12
+ if (typeof value === 'string') return value;
13
+ if (typeof value === 'number') return String(value);
14
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
15
+
16
+ // Object with label/name/identifier fields (Shape, QResult, etc.)
17
+ if (typeof value === 'object') {
18
+ if (value.label) return String(value.label);
19
+ if (value.name) return String(value.name);
20
+ if (value.identifier) return String(value.identifier);
21
+
22
+ // NamedNode with URI — try prefixed form, else last URI segment
23
+ const uri = value.uri || value.id;
24
+ if (typeof uri === 'string' && uri.length > 0) {
25
+ return toPrefixedOrSegment(uri);
26
+ }
27
+
28
+ // Literal with .value
29
+ if (value.value != null) return String(value.value);
30
+ }
31
+
32
+ // Final fallback — never [object Object]
33
+ return String(value);
34
+ }
35
+
36
+ /**
37
+ * Convert a raw enum value label into a human-readable string.
38
+ * - camelCase → "Camel Case" (e.g., "inPerson" → "In Person")
39
+ * - already spaced/titled → unchanged
40
+ * - short lowercase → capitalized (e.g., "home" → "Home")
41
+ */
42
+ export function humanizeEnumLabel(raw: string): string {
43
+ if (!raw) return '';
44
+ // If it already contains spaces, just ensure first letter is capitalized
45
+ if (raw.includes(' ')) {
46
+ return raw.charAt(0).toUpperCase() + raw.slice(1);
47
+ }
48
+ // Split on camelCase boundaries: "inPerson" → ["in", "Person"]
49
+ const parts = raw.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
50
+ return parts
51
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
52
+ .join(' ');
53
+ }
54
+
55
+ /**
56
+ * Try to get a prefixed URI (e.g., "irlcg:Team"), else extract the last path segment.
57
+ */
58
+ function toPrefixedOrSegment(uri: string): string {
59
+ try {
60
+ const prefixed = Prefix.toPrefixedIfPossible(uri);
61
+ if (prefixed !== uri) return prefixed;
62
+ } catch {
63
+ // Prefix resolution not available
64
+ }
65
+ // Extract last path segment or fragment
66
+ const hashIdx = uri.lastIndexOf('#');
67
+ if (hashIdx >= 0) return uri.slice(hashIdx + 1);
68
+ const slashIdx = uri.lastIndexOf('/');
69
+ if (slashIdx >= 0) return uri.slice(slashIdx + 1);
70
+ return uri;
71
+ }
72
+
73
+ /** The part of an IRI a person reads: everything after the last `#` or `/`. */
74
+ function localPart(iri: string): string {
75
+ const hash = iri.lastIndexOf('#');
76
+ if (hash >= 0) return iri.slice(hash + 1);
77
+ const slash = iri.lastIndexOf('/');
78
+ return slash >= 0 ? iri.slice(slash + 1) : iri;
79
+ }
80
+
81
+ /**
82
+ * The choices for an enumerated property, as `{id, label}` pairs.
83
+ *
84
+ * `sh:in` holds its members as the metamodel does — an IRI stays a reference, a literal stays
85
+ * a literal — and carries **no label**. The DTO this package used to take carried `inValues`
86
+ * with a label attached, but that label was never in the graph: it was the IRI's local part,
87
+ * derived at projection time. Deriving it here instead is the same answer from one place, and
88
+ * it is what lets the components drop the DTO.
89
+ *
90
+ * Three call sites needed it — the filter drawer, the form field, and validation — and each
91
+ * had its own idea of how to unwrap a member. One rule now.
92
+ */
93
+ export function enumOptions(property: {
94
+ in?: unknown[];
95
+ /** The DTO's spelling, still accepted so a host holding one is not turned away. */
96
+ inValues?: {id: string; label?: string}[];
97
+ }): {id: string; label: string}[] {
98
+ const members = property.in ?? property.inValues;
99
+ if (!Array.isArray(members) || members.length === 0) return [];
100
+ return members.map((member) => {
101
+ if (member !== null && typeof member === 'object') {
102
+ const {id, label} = member as {id?: unknown; label?: unknown};
103
+ const value = String(id ?? '');
104
+ return {
105
+ id: value,
106
+ label: typeof label === 'string' ? label : humanizeEnumLabel(localPart(value)),
107
+ };
108
+ }
109
+ // A literal enumeration — `sh:in ("red" "green")`. Its own value is its identity, and
110
+ // there is no IRI to take a local part of.
111
+ const value = String(member);
112
+ return {id: value, label: humanizeEnumLabel(value)};
113
+ });
114
+ }