@_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.
Files changed (210) 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 +37 -0
  36. package/lib/esm/components/InstanceOverview.js +233 -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 +50 -0
  51. package/lib/esm/components/ReactTable.js +370 -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 +64 -10
  65. package/lib/esm/index.js +63 -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 +374 -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 +991 -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 +99 -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/lib/esm/package.d.ts +0 -3
  207. package/lib/esm/package.js +0 -4
  208. package/lib/esm/package.js.map +0 -1
  209. package/tsconfig-cjs.json +0 -8
  210. 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
+ });