@_linked/shape-ui 2.0.0 → 2.2.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 +37 -0
- package/lib/esm/components/InstanceOverview.js +233 -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 +50 -0
- package/lib/esm/components/ReactTable.js +370 -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 +64 -10
- package/lib/esm/index.js +63 -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 +374 -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 +991 -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 +99 -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/lib/esm/package.d.ts +0 -3
- package/lib/esm/package.js +0 -4
- package/lib/esm/package.js.map +0 -1
- package/tsconfig-cjs.json +0 -8
- package/tsconfig-esm.json +0 -9
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { NodeShapeWire } from '@_linked/core/shapes/nodeShapeWire';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type PropertyDependency,
|
|
5
|
+
buildDependencyMap,
|
|
6
|
+
computeDependencies,
|
|
7
|
+
extractNarrowingIds,
|
|
8
|
+
} from './shapeDependencies.js';
|
|
9
|
+
import { getShapeParamsFromUri } from './naming.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dependency state for a single child field.
|
|
13
|
+
* Passed down to DynamicFormField via props.
|
|
14
|
+
*/
|
|
15
|
+
export interface FieldDependencyState {
|
|
16
|
+
/** IDs the child should be narrowed to (null = show all, [] = show none) */
|
|
17
|
+
narrowedIds: string[] | null;
|
|
18
|
+
/** Whether the parent field has a value selected */
|
|
19
|
+
parentHasValue: boolean;
|
|
20
|
+
/** Human-readable parent labels (for "Select X first" hint) */
|
|
21
|
+
parentLabels: string[];
|
|
22
|
+
/** Whether narrowing data is currently being fetched */
|
|
23
|
+
loading: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook that computes and manages field dependencies for a form.
|
|
28
|
+
* Infers dependency relationships from the shape graph, tracks parent
|
|
29
|
+
* field changes, and fetches narrowing data.
|
|
30
|
+
*/
|
|
31
|
+
/** Read one instance's data. Injected so this hook holds no control-plane dependency. */
|
|
32
|
+
export type ReadInstance = (
|
|
33
|
+
shapeIri: string,
|
|
34
|
+
instanceId: string,
|
|
35
|
+
) => Promise<{rows: Record<string, unknown>[]}>;
|
|
36
|
+
|
|
37
|
+
export function useFormDependencies(
|
|
38
|
+
shape: NodeShapeWire | null,
|
|
39
|
+
allShapes: Record<string, NodeShapeWire> | null,
|
|
40
|
+
projectId: string | null,
|
|
41
|
+
readInstance?: ReadInstance,
|
|
42
|
+
) {
|
|
43
|
+
// Compute static dependency graph (only changes when shape changes)
|
|
44
|
+
const dependencies = useMemo(() => {
|
|
45
|
+
if (!shape || !allShapes) return [];
|
|
46
|
+
return computeDependencies(shape, allShapes);
|
|
47
|
+
}, [shape?.id, allShapes]);
|
|
48
|
+
|
|
49
|
+
const depMap = useMemo(() => buildDependencyMap(dependencies), [dependencies]);
|
|
50
|
+
|
|
51
|
+
// State for narrowing results
|
|
52
|
+
const [narrowedIds, setNarrowedIds] = useState<Map<string, string[] | null>>(
|
|
53
|
+
new Map()
|
|
54
|
+
);
|
|
55
|
+
const [parentHasValue, setParentHasValue] = useState<Map<string, boolean>>(
|
|
56
|
+
new Map()
|
|
57
|
+
);
|
|
58
|
+
const [loading, setLoading] = useState<Map<string, boolean>>(new Map());
|
|
59
|
+
|
|
60
|
+
// Abort controllers for in-flight fetches
|
|
61
|
+
const fetchControllers = useRef<Map<string, AbortController>>(new Map());
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Called when a parent field value changes.
|
|
65
|
+
* Fetches the parent instance data and extracts narrowing IDs for dependent fields.
|
|
66
|
+
*/
|
|
67
|
+
const onParentValueChange = useCallback(
|
|
68
|
+
async (parentLabel: string, parentValue: any) => {
|
|
69
|
+
// Find all dependencies where this property is the parent
|
|
70
|
+
const childDeps = dependencies.filter((d) => d.parentLabel === parentLabel);
|
|
71
|
+
if (childDeps.length === 0) return;
|
|
72
|
+
|
|
73
|
+
// Extract parent instance ID
|
|
74
|
+
const parentId = parentValue
|
|
75
|
+
? typeof parentValue === 'object'
|
|
76
|
+
? parentValue.id
|
|
77
|
+
: typeof parentValue === 'string'
|
|
78
|
+
? parentValue
|
|
79
|
+
: null
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
// Update parentHasValue for all children of this parent
|
|
83
|
+
setParentHasValue((prev) => {
|
|
84
|
+
const next = new Map(prev);
|
|
85
|
+
for (const dep of childDeps) {
|
|
86
|
+
next.set(dep.childLabel, !!parentId);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!parentId || !projectId) {
|
|
92
|
+
// Parent cleared — remove narrowing for all children
|
|
93
|
+
setNarrowedIds((prev) => {
|
|
94
|
+
const next = new Map(prev);
|
|
95
|
+
for (const dep of childDeps) {
|
|
96
|
+
next.set(dep.childLabel, null);
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Cancel any in-flight fetch for this parent
|
|
104
|
+
const existing = fetchControllers.current.get(parentLabel);
|
|
105
|
+
if (existing) existing.abort();
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
fetchControllers.current.set(parentLabel, controller);
|
|
108
|
+
|
|
109
|
+
// Set loading state
|
|
110
|
+
setLoading((prev) => {
|
|
111
|
+
const next = new Map(prev);
|
|
112
|
+
for (const dep of childDeps) {
|
|
113
|
+
next.set(dep.childLabel, true);
|
|
114
|
+
}
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Convert parent valueShape URI to package:label format for getInstanceData
|
|
120
|
+
const parentShapeParam = getShapeParamsFromUri(
|
|
121
|
+
childDeps[0].parentValueShapeId
|
|
122
|
+
);
|
|
123
|
+
if (!readInstance) return;
|
|
124
|
+
const result = await readInstance(parentShapeParam, parentId);
|
|
125
|
+
|
|
126
|
+
if (controller.signal.aborted) return;
|
|
127
|
+
|
|
128
|
+
const instanceData = Array.isArray(result?.rows)
|
|
129
|
+
? result.rows[0]
|
|
130
|
+
: result?.rows;
|
|
131
|
+
|
|
132
|
+
if (!instanceData) {
|
|
133
|
+
// Could not fetch parent data — allow all options
|
|
134
|
+
setNarrowedIds((prev) => {
|
|
135
|
+
const next = new Map(prev);
|
|
136
|
+
for (const dep of childDeps) {
|
|
137
|
+
next.set(dep.childLabel, null);
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Extract narrowing IDs for each child
|
|
145
|
+
setNarrowedIds((prev) => {
|
|
146
|
+
const next = new Map(prev);
|
|
147
|
+
for (const dep of childDeps) {
|
|
148
|
+
const ids = extractNarrowingIds(
|
|
149
|
+
instanceData,
|
|
150
|
+
dep.linkingPropertyLabel
|
|
151
|
+
);
|
|
152
|
+
next.set(dep.childLabel, ids.length > 0 ? ids : null);
|
|
153
|
+
}
|
|
154
|
+
return next;
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (!controller.signal.aborted) {
|
|
158
|
+
console.warn(
|
|
159
|
+
'Failed to fetch parent instance for narrowing:',
|
|
160
|
+
err
|
|
161
|
+
);
|
|
162
|
+
// On error, don't narrow (show all options)
|
|
163
|
+
setNarrowedIds((prev) => {
|
|
164
|
+
const next = new Map(prev);
|
|
165
|
+
for (const dep of childDeps) {
|
|
166
|
+
next.set(dep.childLabel, null);
|
|
167
|
+
}
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
if (!controller.signal.aborted) {
|
|
173
|
+
setLoading((prev) => {
|
|
174
|
+
const next = new Map(prev);
|
|
175
|
+
for (const dep of childDeps) {
|
|
176
|
+
next.set(dep.childLabel, false);
|
|
177
|
+
}
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[dependencies, projectId]
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Initialize dependency state from existing form data (draft restore / edit mode).
|
|
188
|
+
* Call this after loading initial data to set parentHasValue for pre-filled fields.
|
|
189
|
+
*/
|
|
190
|
+
const initFromData = useCallback(
|
|
191
|
+
(data: Record<string, any>) => {
|
|
192
|
+
if (!data) return;
|
|
193
|
+
// Find all parent labels and trigger onParentValueChange for each that has a value
|
|
194
|
+
const parentLabels = new Set(dependencies.map((d) => d.parentLabel));
|
|
195
|
+
for (const label of parentLabels) {
|
|
196
|
+
if (data[label] !== undefined && data[label] !== null) {
|
|
197
|
+
onParentValueChange(label, data[label]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[dependencies, onParentValueChange]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Cleanup on unmount
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
return () => {
|
|
207
|
+
fetchControllers.current.forEach((c) => c.abort());
|
|
208
|
+
};
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
/** Check if a property has any parent dependencies */
|
|
212
|
+
const hasDependency = useCallback(
|
|
213
|
+
(label: string) => depMap.has(label),
|
|
214
|
+
[depMap]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
/** Check if a property is a parent (has children that depend on it) */
|
|
218
|
+
const isParent = useCallback(
|
|
219
|
+
(label: string) => dependencies.some((d) => d.parentLabel === label),
|
|
220
|
+
[dependencies]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
/** Get the parent labels for a given child */
|
|
224
|
+
const getParentLabels = useCallback(
|
|
225
|
+
(childLabel: string) =>
|
|
226
|
+
(depMap.get(childLabel) || []).map((d) => d.parentLabel),
|
|
227
|
+
[depMap]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
/** Build FieldDependencyState for a given property label */
|
|
231
|
+
const getDependencyState = useCallback(
|
|
232
|
+
(label: string): FieldDependencyState | undefined => {
|
|
233
|
+
if (!depMap.has(label)) return undefined;
|
|
234
|
+
return {
|
|
235
|
+
narrowedIds: narrowedIds.get(label) ?? null,
|
|
236
|
+
parentHasValue: parentHasValue.get(label) ?? true,
|
|
237
|
+
parentLabels: getParentLabels(label),
|
|
238
|
+
loading: loading.get(label) ?? false,
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
[depMap, narrowedIds, parentHasValue, loading, getParentLabels]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
dependencies,
|
|
246
|
+
depMap,
|
|
247
|
+
narrowedIds,
|
|
248
|
+
parentHasValue,
|
|
249
|
+
loading,
|
|
250
|
+
onParentValueChange,
|
|
251
|
+
initFromData,
|
|
252
|
+
hasDependency,
|
|
253
|
+
isParent,
|
|
254
|
+
getParentLabels,
|
|
255
|
+
getDependencyState,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {enumOptions} from './nodeDisplay.js';
|
|
2
|
+
import type { PropertyShapeWire } from '@_linked/core/shapes/nodeShapeWire';
|
|
3
|
+
import { shacl } from '@_linked/core/ontologies/shacl';
|
|
4
|
+
|
|
5
|
+
export interface FieldError {
|
|
6
|
+
propertyLabel: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate a single field value against its PropertyShape constraints.
|
|
12
|
+
* Returns an error message string, or empty string if valid.
|
|
13
|
+
*/
|
|
14
|
+
export function validateField(
|
|
15
|
+
property: PropertyShapeWire,
|
|
16
|
+
value: any,
|
|
17
|
+
): string {
|
|
18
|
+
const nodeKind = property.nodeKind?.id;
|
|
19
|
+
const isIRI =
|
|
20
|
+
nodeKind === shacl.IRI.id ||
|
|
21
|
+
nodeKind === shacl.BlankNode.id ||
|
|
22
|
+
nodeKind === shacl.BlankNodeOrIRI.id;
|
|
23
|
+
|
|
24
|
+
// Required check (minCount > 0)
|
|
25
|
+
if (property.minCount > 0) {
|
|
26
|
+
if (isIRI) {
|
|
27
|
+
// For IRI properties, value is an object or array of objects
|
|
28
|
+
const isEmpty =
|
|
29
|
+
value === undefined ||
|
|
30
|
+
value === null ||
|
|
31
|
+
(Array.isArray(value) && value.length === 0);
|
|
32
|
+
if (isEmpty) return 'At least one selection is required';
|
|
33
|
+
} else {
|
|
34
|
+
if (value === undefined || value === null || value === '') {
|
|
35
|
+
return 'This field is required';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Skip further validation if value is empty (optional fields)
|
|
41
|
+
if (value === undefined || value === null || value === '') return '';
|
|
42
|
+
|
|
43
|
+
// sh:in — value must be in the allowed set
|
|
44
|
+
const choices = enumOptions(property);
|
|
45
|
+
if (choices.length > 0) {
|
|
46
|
+
if (isIRI) {
|
|
47
|
+
const selected = Array.isArray(value) ? value : [value];
|
|
48
|
+
const allowedIds = new Set(choices.map((v) => v.id));
|
|
49
|
+
const invalid = selected.find((s) => !allowedIds.has(s.id));
|
|
50
|
+
if (invalid) return 'Must be one of the allowed values';
|
|
51
|
+
} else {
|
|
52
|
+
const allowedIds = new Set(choices.map((v) => v.id));
|
|
53
|
+
if (!allowedIds.has(String(value))) {
|
|
54
|
+
return 'Must be one of the allowed values';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Literal-only constraints
|
|
60
|
+
if (nodeKind === shacl.Literal.id) {
|
|
61
|
+
const strVal = typeof value === 'string' ? value : String(value);
|
|
62
|
+
|
|
63
|
+
// sh:pattern
|
|
64
|
+
if (property.pattern) {
|
|
65
|
+
try {
|
|
66
|
+
const re = new RegExp(property.pattern);
|
|
67
|
+
if (!re.test(strVal)) return 'Value must match the required format';
|
|
68
|
+
} catch {
|
|
69
|
+
// Invalid regex in shape definition — skip
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// sh:minLength
|
|
74
|
+
if (property.minLength != null && strVal.length < property.minLength) {
|
|
75
|
+
return `Must be at least ${property.minLength} characters`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// sh:maxLength
|
|
79
|
+
if (property.maxLength != null && strVal.length > property.maxLength) {
|
|
80
|
+
return `Must be at most ${property.maxLength} characters`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Numeric range constraints
|
|
84
|
+
const numVal = typeof value === 'number' ? value : parseFloat(strVal);
|
|
85
|
+
if (!isNaN(numVal)) {
|
|
86
|
+
if (property.minInclusive != null && numVal < Number(property.minInclusive)) {
|
|
87
|
+
return `Must be at least ${property.minInclusive}`;
|
|
88
|
+
}
|
|
89
|
+
if (property.maxInclusive != null && numVal > Number(property.maxInclusive)) {
|
|
90
|
+
return `Must be at most ${property.maxInclusive}`;
|
|
91
|
+
}
|
|
92
|
+
if (property.minExclusive != null && numVal <= Number(property.minExclusive)) {
|
|
93
|
+
return `Must be greater than ${property.minExclusive}`;
|
|
94
|
+
}
|
|
95
|
+
if (property.maxExclusive != null && numVal >= Number(property.maxExclusive)) {
|
|
96
|
+
return `Must be less than ${property.maxExclusive}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate all fields at once (for submit-time gate).
|
|
106
|
+
* Returns an array of errors; empty array means all valid.
|
|
107
|
+
*/
|
|
108
|
+
export function validateAllFields(
|
|
109
|
+
properties: PropertyShapeWire[],
|
|
110
|
+
data: Record<string, any>,
|
|
111
|
+
): FieldError[] {
|
|
112
|
+
const errors: FieldError[] = [];
|
|
113
|
+
for (const prop of properties) {
|
|
114
|
+
if (!prop.nodeKind) continue; // skip incomplete shapes
|
|
115
|
+
const msg = validateField(prop, data[prop.label]);
|
|
116
|
+
if (msg) {
|
|
117
|
+
errors.push({ propertyLabel: prop.label, message: msg });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
package/src/tokens.css
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The few tokens the data manager still defines itself.
|
|
3
|
+
*
|
|
4
|
+
* It used to be 67. Measuring them against `@_linked/css` showed almost all were either
|
|
5
|
+
* Tailwind primitives the theme already assumes, or re-namings of tokens the theme has
|
|
6
|
+
* under better names — `--card-*` is `--surface-*`, `--input-*` is `--field-*`,
|
|
7
|
+
* `--z-tooltip` is `--layer-tooltip`, `--typography-xs-size` is `--text-xs`. Those were
|
|
8
|
+
* renamed in the components rather than shipped here. The families that were genuinely
|
|
9
|
+
* missing went into the theme instead of here: `--intent-*` and `--table-*`
|
|
10
|
+
* (linked-cm/css#11), and the named `--space-*` scale (linked-cm/css#12) that a CSS module
|
|
11
|
+
* needs because Tailwind's `--spacing()` is a compile-time function and cannot resolve in a
|
|
12
|
+
* stylesheet compiled on its own.
|
|
13
|
+
*
|
|
14
|
+
* What is left is genuinely local: pagination has no counterpart in the theme's component
|
|
15
|
+
* families. Values come from Create Now's light theme so the two do not drift on day one.
|
|
16
|
+
* Import before your own theme; anything you define afterwards wins.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
:root {
|
|
20
|
+
--pagination-button-border: var(--color-gray-200);
|
|
21
|
+
--pagination-button-hover-border: var(--color-tertiary-500);
|
|
22
|
+
--pagination-button-hover-text: var(--color-tertiary-500);
|
|
23
|
+
--pagination-text: var(--color-gray-500);
|
|
24
|
+
--pagination-text-bold: var(--color-gray-900);
|
|
25
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS modules, for the compiler.
|
|
3
|
+
*
|
|
4
|
+
* `tsc` only COPIES these into `lib` (see `copy-to-lib`); the class-name hashing and scoping is
|
|
5
|
+
* the consuming bundler's job, as in `@_linked/primitives`. A consumer therefore needs a
|
|
6
|
+
* bundler that understands CSS modules — which any app rendering these components already has.
|
|
7
|
+
*
|
|
8
|
+
* The binding is called `styles`, not `classes`, on purpose. `@_linked/primitives` declares the
|
|
9
|
+
* same ambient module, and inside this workspace its *source* is symlinked, so both
|
|
10
|
+
* declarations are visible and `const classes` in each is a duplicate identifier. Ambient
|
|
11
|
+
* module declarations merge; the names inside them must not collide. Renaming is the whole fix.
|
|
12
|
+
*
|
|
13
|
+
* The declaration cannot simply be dropped either: primitives' version reaches us only through
|
|
14
|
+
* the workspace symlink, so a clean `npm ci` in CI has no declaration at all and every
|
|
15
|
+
* `import style from './X.module.css'` fails.
|
|
16
|
+
*/
|
|
17
|
+
declare module '*.module.css' {
|
|
18
|
+
const styles: {readonly [key: string]: string};
|
|
19
|
+
export default styles;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
|
|
3
|
+
// currently use for generate
|
|
4
|
+
export function generateRandomName(format: string) {
|
|
5
|
+
const timestamp = new Date().getTime();
|
|
6
|
+
const randomString = Math.random().toString(15).substring(7); // Generate a random string
|
|
7
|
+
|
|
8
|
+
return format
|
|
9
|
+
? `${timestamp}_${randomString}.${format}`
|
|
10
|
+
: `${timestamp}_${randomString}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fixes the image path for Android devices during development.
|
|
15
|
+
* Replaces 'localhost' with the appropriate site root URL.
|
|
16
|
+
* @param croppedImage
|
|
17
|
+
* @returns
|
|
18
|
+
*/
|
|
19
|
+
export function replaceLocalhostWithSiteRoot(croppedImage: string): string {
|
|
20
|
+
let imageSrc = croppedImage;
|
|
21
|
+
if (
|
|
22
|
+
imageSrc &&
|
|
23
|
+
process.env.NODE_ENV !== 'production' &&
|
|
24
|
+
Capacitor.getPlatform() === 'android'
|
|
25
|
+
) {
|
|
26
|
+
if (imageSrc.includes('localhost')) {
|
|
27
|
+
imageSrc = imageSrc.replace(
|
|
28
|
+
/http\:\/\/localhost\:\d+/,
|
|
29
|
+
process.env.SITE_ROOT
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return imageSrc;
|
|
35
|
+
}
|
|
@@ -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 write path.
|
|
6
|
+
*
|
|
7
|
+
* The read moved to the DSL a while ago; the write was still a control-plane RPC that took
|
|
8
|
+
* a project id. These pin the two things that made that a problem: the write names no
|
|
9
|
+
* project, and it goes through the same builder — and therefore the same routing — as the
|
|
10
|
+
* read. A read routed one way and a write routed another is how a form saves into the
|
|
11
|
+
* wrong dataset without anyone noticing.
|
|
12
|
+
*
|
|
13
|
+
* The builders are mocked. What is asserted is the shape of the call; core's own suite
|
|
14
|
+
* covers the SPARQL.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const createFrom = jest.fn();
|
|
18
|
+
const createWithId = jest.fn();
|
|
19
|
+
const createSet = jest.fn();
|
|
20
|
+
const updateFrom = jest.fn();
|
|
21
|
+
const updateFor = jest.fn();
|
|
22
|
+
const updateSet = jest.fn();
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule('@_linked/core/queries/CreateBuilder', () => ({
|
|
25
|
+
CreateBuilder: {
|
|
26
|
+
from: (...args: unknown[]) => {
|
|
27
|
+
createFrom(...args);
|
|
28
|
+
const b: Record<string, unknown> = {};
|
|
29
|
+
b.withId = (id: unknown) => (createWithId(id), b);
|
|
30
|
+
b.set = (data: unknown) => (createSet(data), b);
|
|
31
|
+
b.exec = async () => undefined;
|
|
32
|
+
return b;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.unstable_mockModule('@_linked/core/queries/UpdateBuilder', () => ({
|
|
38
|
+
UpdateBuilder: {
|
|
39
|
+
from: (...args: unknown[]) => {
|
|
40
|
+
updateFrom(...args);
|
|
41
|
+
const b: Record<string, unknown> = {};
|
|
42
|
+
b.for = (id: unknown) => (updateFor(id), b);
|
|
43
|
+
b.set = (data: unknown) => (updateSet(data), b);
|
|
44
|
+
b.exec = async () => undefined;
|
|
45
|
+
return b;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const {createInstanceWithDsl, updateInstanceWithDsl, mintInstanceIri} = await import(
|
|
51
|
+
'./write.js'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const shape: NodeShapeWire = {
|
|
55
|
+
id: 'https://id.linked.cm/app/vocab#Article',
|
|
56
|
+
label: 'Article',
|
|
57
|
+
propertyShapes: [
|
|
58
|
+
{id: 'p1', label: 'title', path: {id: 'http://schema.org/name'}},
|
|
59
|
+
{id: 'p2', label: 'author', path: {id: 'http://schema.org/author'}},
|
|
60
|
+
{id: 'p3', label: 'tags', path: {id: 'http://schema.org/keywords'}},
|
|
61
|
+
],
|
|
62
|
+
} as NodeShapeWire;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
[createFrom, createWithId, createSet, updateFrom, updateFor, updateSet].forEach((m) =>
|
|
66
|
+
m.mockClear(),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('creating', () => {
|
|
71
|
+
test('names the shape and no project', async () => {
|
|
72
|
+
await createInstanceWithDsl(shape, {title: 'Dune'}, {dataRoot: 'https://ex.org/data'});
|
|
73
|
+
expect(createFrom).toHaveBeenCalledWith(shape.id);
|
|
74
|
+
// The portability claim, asserted rather than asserted-in-prose: nothing in the call
|
|
75
|
+
// carries a project, a branch or a dataset.
|
|
76
|
+
expect(JSON.stringify(createSet.mock.calls)).not.toMatch(/project|branch|dataset/i);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('mints an id under the host dataRoot', async () => {
|
|
80
|
+
const {id} = await createInstanceWithDsl(
|
|
81
|
+
shape,
|
|
82
|
+
{title: 'Dune'},
|
|
83
|
+
{dataRoot: 'https://ex.org/data/'},
|
|
84
|
+
);
|
|
85
|
+
// Trailing slash on dataRoot must not produce a double slash.
|
|
86
|
+
expect(id).toMatch(/^https:\/\/ex\.org\/data\/article\/[0-9a-f-]{36}$/);
|
|
87
|
+
expect(createWithId).toHaveBeenCalledWith(id);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('refuses to invent a namespace when dataRoot is missing', async () => {
|
|
91
|
+
// A node created under a guessed IRI is worse than a failed create: it is unreachable
|
|
92
|
+
// and it is already written.
|
|
93
|
+
await expect(createInstanceWithDsl(shape, {title: 'Dune'})).rejects.toThrow(/dataRoot/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('drops empty values instead of writing empty literals', async () => {
|
|
97
|
+
await createInstanceWithDsl(
|
|
98
|
+
shape,
|
|
99
|
+
{title: 'Dune', author: '', tags: undefined},
|
|
100
|
+
{dataRoot: 'https://ex.org/data'},
|
|
101
|
+
);
|
|
102
|
+
expect(createSet).toHaveBeenCalledWith({title: 'Dune'});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('drops fields the shape does not declare', async () => {
|
|
106
|
+
await createInstanceWithDsl(
|
|
107
|
+
shape,
|
|
108
|
+
{title: 'Dune', removedLastWeek: 'x'},
|
|
109
|
+
{dataRoot: 'https://ex.org/data'},
|
|
110
|
+
);
|
|
111
|
+
expect(createSet).toHaveBeenCalledWith({title: 'Dune'});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('writes a relation as a reference, without its display label', async () => {
|
|
115
|
+
// The form carries {id, label} so a draft can show a name. The label is display state;
|
|
116
|
+
// writing it would put a second, immediately stale copy of it in the graph.
|
|
117
|
+
await createInstanceWithDsl(
|
|
118
|
+
shape,
|
|
119
|
+
{author: {id: 'https://ex.org/person/1', label: 'Ada'}},
|
|
120
|
+
{dataRoot: 'https://ex.org/data'},
|
|
121
|
+
);
|
|
122
|
+
expect(createSet).toHaveBeenCalledWith({author: {id: 'https://ex.org/person/1'}});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('does the same for every member of a multi-valued relation', async () => {
|
|
126
|
+
await createInstanceWithDsl(
|
|
127
|
+
shape,
|
|
128
|
+
{tags: [{id: 'urn:t/1', label: 'alpha'}, {id: 'urn:t/2', label: 'beta'}]},
|
|
129
|
+
{dataRoot: 'https://ex.org/data'},
|
|
130
|
+
);
|
|
131
|
+
expect(createSet).toHaveBeenCalledWith({tags: [{id: 'urn:t/1'}, {id: 'urn:t/2'}]});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('accepts a caller-supplied id', async () => {
|
|
135
|
+
const {id} = await createInstanceWithDsl(
|
|
136
|
+
shape,
|
|
137
|
+
{title: 'Dune'},
|
|
138
|
+
{id: 'https://ex.org/data/article/fixed'},
|
|
139
|
+
);
|
|
140
|
+
expect(id).toBe('https://ex.org/data/article/fixed');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('updating', () => {
|
|
145
|
+
test('targets the instance by id and names no project', async () => {
|
|
146
|
+
await updateInstanceWithDsl(shape, 'https://ex.org/data/article/1', {title: 'Dune'});
|
|
147
|
+
expect(updateFrom).toHaveBeenCalledWith(shape.id);
|
|
148
|
+
expect(updateFor).toHaveBeenCalledWith('https://ex.org/data/article/1');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('clears an emptied field rather than dropping it', async () => {
|
|
152
|
+
// The opposite of create, and deliberately so: clearing a field the viewer emptied is
|
|
153
|
+
// the whole point of an edit form.
|
|
154
|
+
await updateInstanceWithDsl(shape, 'urn:a/1', {title: '', author: undefined});
|
|
155
|
+
expect(updateSet).toHaveBeenCalledWith({title: null, author: null});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('is a no-op when nothing in the payload is recognisable', async () => {
|
|
159
|
+
await updateInstanceWithDsl(shape, 'urn:a/1', {goneFromTheShape: 'x'});
|
|
160
|
+
expect(updateFrom).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('mintInstanceIri', () => {
|
|
165
|
+
test('uses the shape label as the path segment, lowercased', () => {
|
|
166
|
+
expect(mintInstanceIri(shape, 'https://ex.org/d')).toContain('/article/');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('falls back to a generic segment for a shape with no label', () => {
|
|
170
|
+
expect(mintInstanceIri({id: 'urn:s'} as NodeShapeWire, 'https://ex.org/d')).toContain(
|
|
171
|
+
'/instance/',
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|