@_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.
- package/lib/esm/columns.d.ts +58 -0
- package/lib/esm/columns.js +173 -0
- package/lib/esm/columns.js.map +1 -0
- package/lib/esm/components/AddInstanceForms.d.ts +38 -0
- package/lib/esm/components/AddInstanceForms.js +143 -0
- package/lib/esm/components/AddInstanceForms.js.map +1 -0
- package/lib/esm/components/AddInstanceForms.module.css +12 -0
- package/lib/esm/components/BatchEditDrawer.d.ts +13 -0
- package/lib/esm/components/BatchEditDrawer.js +122 -0
- package/lib/esm/components/BatchEditDrawer.js.map +1 -0
- package/lib/esm/components/BatchEditDrawer.module.css +193 -0
- package/lib/esm/components/CustomMultiSelect.d.ts +26 -0
- package/lib/esm/components/CustomMultiSelect.js +344 -0
- package/lib/esm/components/CustomMultiSelect.js.map +1 -0
- package/lib/esm/components/CustomMultiSelect.module.css +291 -0
- package/lib/esm/components/DynamicFormField.d.ts +21 -0
- package/lib/esm/components/DynamicFormField.js +149 -0
- package/lib/esm/components/DynamicFormField.js.map +1 -0
- package/lib/esm/components/DynamicFormField.module.css +169 -0
- package/lib/esm/components/EditInstanceForms.d.ts +15 -0
- package/lib/esm/components/EditInstanceForms.js +118 -0
- package/lib/esm/components/EditInstanceForms.js.map +1 -0
- package/lib/esm/components/FilterBadge.d.ts +20 -0
- package/lib/esm/components/FilterBadge.js +26 -0
- package/lib/esm/components/FilterBadge.js.map +1 -0
- package/lib/esm/components/FilterBadge.module.css +25 -0
- package/lib/esm/components/FormField.d.ts +18 -0
- package/lib/esm/components/FormField.js +9 -0
- package/lib/esm/components/FormField.js.map +1 -0
- package/lib/esm/components/FormField.module.css +57 -0
- package/lib/esm/components/InstanceDeletionDialog.d.ts +17 -0
- package/lib/esm/components/InstanceDeletionDialog.js +83 -0
- package/lib/esm/components/InstanceDeletionDialog.js.map +1 -0
- package/lib/esm/components/InstanceDeletionDialog.module.css +59 -0
- package/lib/esm/components/InstanceOverview.d.ts +43 -0
- package/lib/esm/components/InstanceOverview.js +236 -0
- package/lib/esm/components/InstanceOverview.js.map +1 -0
- package/lib/esm/components/InstanceOverview.module.css +35 -0
- package/lib/esm/components/InstanceView.d.ts +29 -0
- package/lib/esm/components/InstanceView.js +97 -0
- package/lib/esm/components/InstanceView.js.map +1 -0
- package/lib/esm/components/InstanceView.module.css +157 -0
- package/lib/esm/components/NodeBadge.d.ts +25 -0
- package/lib/esm/components/NodeBadge.js +29 -0
- package/lib/esm/components/NodeBadge.js.map +1 -0
- package/lib/esm/components/NodeValuesEditor.d.ts +15 -0
- package/lib/esm/components/NodeValuesEditor.js +29 -0
- package/lib/esm/components/NodeValuesEditor.js.map +1 -0
- package/lib/esm/components/NodeValuesEditor.module.css +4 -0
- package/lib/esm/components/ReactTable.d.ts +59 -0
- package/lib/esm/components/ReactTable.js +378 -0
- package/lib/esm/components/ReactTable.js.map +1 -0
- package/lib/esm/components/ReactTable.module.css +950 -0
- package/lib/esm/components/TextfieldEditor.d.ts +1 -2
- package/lib/esm/components/TextfieldEditor.js +2 -2
- package/lib/esm/components/TextfieldEditor.js.map +1 -1
- package/lib/esm/components/forms.module.css +96 -0
- package/lib/esm/host.d.ts +196 -0
- package/lib/esm/host.js +25 -0
- package/lib/esm/host.js.map +1 -0
- package/lib/esm/hostContext.d.ts +34 -0
- package/lib/esm/hostContext.js +95 -0
- package/lib/esm/hostContext.js.map +1 -0
- package/lib/esm/index.d.ts +60 -10
- package/lib/esm/index.js +60 -14
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/read.d.ts +48 -0
- package/lib/esm/read.js +96 -0
- package/lib/esm/read.js.map +1 -0
- package/lib/esm/search.d.ts +32 -0
- package/lib/esm/search.js +87 -0
- package/lib/esm/search.js.map +1 -0
- package/lib/esm/shape/contracts.d.ts +58 -0
- package/lib/esm/shape/contracts.js +15 -0
- package/lib/esm/shape/contracts.js.map +1 -0
- package/lib/esm/shape/naming.d.ts +30 -0
- package/lib/esm/shape/naming.js +52 -0
- package/lib/esm/shape/naming.js.map +1 -0
- package/lib/esm/shape/nodeDisplay.d.ts +36 -0
- package/lib/esm/shape/nodeDisplay.js +117 -0
- package/lib/esm/shape/nodeDisplay.js.map +1 -0
- package/lib/esm/shape/propertyTypes.d.ts +12 -0
- package/lib/esm/shape/propertyTypes.js +60 -0
- package/lib/esm/shape/propertyTypes.js.map +1 -0
- package/lib/esm/shape/propertyVisibility.d.ts +56 -0
- package/lib/esm/shape/propertyVisibility.js +112 -0
- package/lib/esm/shape/propertyVisibility.js.map +1 -0
- package/lib/esm/shape/shapeDependencies.d.ts +41 -0
- package/lib/esm/shape/shapeDependencies.js +141 -0
- package/lib/esm/shape/shapeDependencies.js.map +1 -0
- package/lib/esm/shape/tableFilters.d.ts +35 -0
- package/lib/esm/shape/tableFilters.js +171 -0
- package/lib/esm/shape/tableFilters.js.map +1 -0
- package/lib/esm/shape/useFormDependencies.d.ts +38 -0
- package/lib/esm/shape/useFormDependencies.js +178 -0
- package/lib/esm/shape/useFormDependencies.js.map +1 -0
- package/lib/esm/shape/validation.d.ts +15 -0
- package/lib/esm/shape/validation.js +106 -0
- package/lib/esm/shape/validation.js.map +1 -0
- package/lib/esm/tokens.css +25 -0
- package/lib/esm/types.d.ts +22 -0
- package/lib/esm/types.js +1 -0
- package/lib/esm/types.js.map +1 -0
- package/lib/esm/write.d.ts +51 -0
- package/lib/esm/write.js +99 -0
- package/lib/esm/write.js.map +1 -0
- package/package.json +44 -18
- package/src/backend.ts +1 -0
- package/src/columns.regression.test.ts +143 -0
- package/src/columns.test.ts +200 -0
- package/src/columns.ts +228 -0
- package/src/components/AddInstanceForms.module.css +12 -0
- package/src/components/AddInstanceForms.tsx +321 -0
- package/src/components/AvatarEditor.tsx +34 -0
- package/src/components/BatchEditDrawer.module.css +193 -0
- package/src/components/BatchEditDrawer.tsx +295 -0
- package/src/components/CheckboxEditor.module.css +6 -0
- package/src/components/CheckboxEditor.tsx +86 -0
- package/src/components/CustomMultiSelect.module.css +291 -0
- package/src/components/CustomMultiSelect.tsx +527 -0
- package/src/components/DateEditor.tsx +50 -0
- package/src/components/DynamicFormField.module.css +169 -0
- package/src/components/DynamicFormField.tsx +357 -0
- package/src/components/EditInstanceForms.tsx +260 -0
- package/src/components/FilterBadge.module.css +25 -0
- package/src/components/FilterBadge.tsx +70 -0
- package/src/components/FormField.module.css +57 -0
- package/src/components/FormField.tsx +59 -0
- package/src/components/ImageValueEditor.module.css +3 -0
- package/src/components/ImageValueEditor.tsx +82 -0
- package/src/components/InstanceDeletionDialog.module.css +59 -0
- package/src/components/InstanceDeletionDialog.tsx +175 -0
- package/src/components/InstanceOverview.module.css +35 -0
- package/src/components/InstanceOverview.tsx +385 -0
- package/src/components/InstanceView.module.css +157 -0
- package/src/components/InstanceView.tsx +212 -0
- package/src/components/NodeBadge.tsx +60 -0
- package/src/components/NodeValuesEditor.module.css +4 -0
- package/src/components/NodeValuesEditor.tsx +64 -0
- package/src/components/RadioButtonEditor.module.css +22 -0
- package/src/components/RadioButtonEditor.tsx +96 -0
- package/src/components/ReactTable.module.css +950 -0
- package/src/components/ReactTable.tsx +1013 -0
- package/src/components/SelectEditor.tsx +85 -0
- package/src/components/ShapeTable.module.css +394 -0
- package/src/components/SwitchEditor.tsx +60 -0
- package/src/components/TextareaEditor.tsx +55 -0
- package/src/components/TextfieldEditor.tsx +67 -0
- package/src/components/ToggleEditor.tsx +63 -0
- package/src/components/forms.module.css +96 -0
- package/src/host.ts +226 -0
- package/src/hostContext.tsx +114 -0
- package/src/index.ts +93 -0
- package/src/package.ts +21 -0
- package/src/read.ts +148 -0
- package/src/search.test.ts +174 -0
- package/src/search.ts +108 -0
- package/src/shape/contracts.ts +66 -0
- package/src/shape/naming.ts +52 -0
- package/src/shape/nodeDisplay.ts +114 -0
- package/src/shape/propertyTypes.ts +86 -0
- package/src/shape/propertyVisibility.ts +180 -0
- package/src/shape/shapeDependencies.ts +183 -0
- package/src/shape/tableFilters.ts +223 -0
- package/src/shape/types.ts +1158 -0
- package/src/shape/useFormDependencies.ts +257 -0
- package/src/shape/validation.ts +121 -0
- package/src/tokens.css +25 -0
- package/src/types.ts +20 -0
- package/src/utils/helper.ts +35 -0
- package/src/write.test.ts +174 -0
- package/src/write.ts +114 -0
- package/.changeset/README.md +0 -20
- package/.changeset/config.json +0 -14
- package/.github/workflows/changeset-check.yml +0 -44
- package/.github/workflows/ci.yml +0 -43
- package/.github/workflows/publish.yml +0 -158
- package/CHANGELOG.md +0 -30
- package/lib/esm/components/AvatarEditor.d.ts +0 -20
- package/lib/esm/components/AvatarEditor.js +0 -7
- package/lib/esm/components/AvatarEditor.js.map +0 -1
- package/lib/esm/components/CheckboxEditor.d.ts +0 -21
- package/lib/esm/components/CheckboxEditor.js +0 -44
- package/lib/esm/components/CheckboxEditor.js.map +0 -1
- package/lib/esm/components/DateEditor.d.ts +0 -20
- package/lib/esm/components/DateEditor.js +0 -22
- package/lib/esm/components/DateEditor.js.map +0 -1
- package/lib/esm/components/RadioButtonEditor.d.ts +0 -23
- package/lib/esm/components/RadioButtonEditor.js +0 -41
- package/lib/esm/components/RadioButtonEditor.js.map +0 -1
- package/lib/esm/components/SelectEditor.d.ts +0 -21
- package/lib/esm/components/SelectEditor.js +0 -30
- package/lib/esm/components/SelectEditor.js.map +0 -1
- package/lib/esm/components/SwitchEditor.d.ts +0 -21
- package/lib/esm/components/SwitchEditor.js +0 -23
- package/lib/esm/components/SwitchEditor.js.map +0 -1
- package/lib/esm/components/TextareaEditor.d.ts +0 -23
- package/lib/esm/components/TextareaEditor.js +0 -15
- package/lib/esm/components/TextareaEditor.js.map +0 -1
- package/lib/esm/components/ToggleEditor.d.ts +0 -22
- package/lib/esm/components/ToggleEditor.js +0 -23
- package/lib/esm/components/ToggleEditor.js.map +0 -1
- package/lib/esm/ontologies/lincd-ui.d.ts +0 -6
- package/lib/esm/ontologies/lincd-ui.js +0 -18
- package/lib/esm/ontologies/lincd-ui.js.map +0 -1
- package/tsconfig-cjs.json +0 -8
- 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
|
+
}
|