@_linked/shape-ui 2.0.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/lib/esm/columns.d.ts +58 -0
  2. package/lib/esm/columns.js +173 -0
  3. package/lib/esm/columns.js.map +1 -0
  4. package/lib/esm/components/AddInstanceForms.d.ts +38 -0
  5. package/lib/esm/components/AddInstanceForms.js +143 -0
  6. package/lib/esm/components/AddInstanceForms.js.map +1 -0
  7. package/lib/esm/components/AddInstanceForms.module.css +12 -0
  8. package/lib/esm/components/BatchEditDrawer.d.ts +13 -0
  9. package/lib/esm/components/BatchEditDrawer.js +122 -0
  10. package/lib/esm/components/BatchEditDrawer.js.map +1 -0
  11. package/lib/esm/components/BatchEditDrawer.module.css +193 -0
  12. package/lib/esm/components/CustomMultiSelect.d.ts +26 -0
  13. package/lib/esm/components/CustomMultiSelect.js +344 -0
  14. package/lib/esm/components/CustomMultiSelect.js.map +1 -0
  15. package/lib/esm/components/CustomMultiSelect.module.css +291 -0
  16. package/lib/esm/components/DynamicFormField.d.ts +21 -0
  17. package/lib/esm/components/DynamicFormField.js +149 -0
  18. package/lib/esm/components/DynamicFormField.js.map +1 -0
  19. package/lib/esm/components/DynamicFormField.module.css +169 -0
  20. package/lib/esm/components/EditInstanceForms.d.ts +15 -0
  21. package/lib/esm/components/EditInstanceForms.js +118 -0
  22. package/lib/esm/components/EditInstanceForms.js.map +1 -0
  23. package/lib/esm/components/FilterBadge.d.ts +20 -0
  24. package/lib/esm/components/FilterBadge.js +26 -0
  25. package/lib/esm/components/FilterBadge.js.map +1 -0
  26. package/lib/esm/components/FilterBadge.module.css +25 -0
  27. package/lib/esm/components/FormField.d.ts +18 -0
  28. package/lib/esm/components/FormField.js +9 -0
  29. package/lib/esm/components/FormField.js.map +1 -0
  30. package/lib/esm/components/FormField.module.css +57 -0
  31. package/lib/esm/components/InstanceDeletionDialog.d.ts +17 -0
  32. package/lib/esm/components/InstanceDeletionDialog.js +83 -0
  33. package/lib/esm/components/InstanceDeletionDialog.js.map +1 -0
  34. package/lib/esm/components/InstanceDeletionDialog.module.css +59 -0
  35. package/lib/esm/components/InstanceOverview.d.ts +43 -0
  36. package/lib/esm/components/InstanceOverview.js +236 -0
  37. package/lib/esm/components/InstanceOverview.js.map +1 -0
  38. package/lib/esm/components/InstanceOverview.module.css +35 -0
  39. package/lib/esm/components/InstanceView.d.ts +29 -0
  40. package/lib/esm/components/InstanceView.js +97 -0
  41. package/lib/esm/components/InstanceView.js.map +1 -0
  42. package/lib/esm/components/InstanceView.module.css +157 -0
  43. package/lib/esm/components/NodeBadge.d.ts +25 -0
  44. package/lib/esm/components/NodeBadge.js +29 -0
  45. package/lib/esm/components/NodeBadge.js.map +1 -0
  46. package/lib/esm/components/NodeValuesEditor.d.ts +15 -0
  47. package/lib/esm/components/NodeValuesEditor.js +29 -0
  48. package/lib/esm/components/NodeValuesEditor.js.map +1 -0
  49. package/lib/esm/components/NodeValuesEditor.module.css +4 -0
  50. package/lib/esm/components/ReactTable.d.ts +59 -0
  51. package/lib/esm/components/ReactTable.js +378 -0
  52. package/lib/esm/components/ReactTable.js.map +1 -0
  53. package/lib/esm/components/ReactTable.module.css +950 -0
  54. package/lib/esm/components/TextfieldEditor.d.ts +1 -2
  55. package/lib/esm/components/TextfieldEditor.js +2 -2
  56. package/lib/esm/components/TextfieldEditor.js.map +1 -1
  57. package/lib/esm/components/forms.module.css +96 -0
  58. package/lib/esm/host.d.ts +196 -0
  59. package/lib/esm/host.js +25 -0
  60. package/lib/esm/host.js.map +1 -0
  61. package/lib/esm/hostContext.d.ts +34 -0
  62. package/lib/esm/hostContext.js +95 -0
  63. package/lib/esm/hostContext.js.map +1 -0
  64. package/lib/esm/index.d.ts +60 -10
  65. package/lib/esm/index.js +60 -14
  66. package/lib/esm/index.js.map +1 -1
  67. package/lib/esm/read.d.ts +48 -0
  68. package/lib/esm/read.js +96 -0
  69. package/lib/esm/read.js.map +1 -0
  70. package/lib/esm/search.d.ts +32 -0
  71. package/lib/esm/search.js +87 -0
  72. package/lib/esm/search.js.map +1 -0
  73. package/lib/esm/shape/contracts.d.ts +58 -0
  74. package/lib/esm/shape/contracts.js +15 -0
  75. package/lib/esm/shape/contracts.js.map +1 -0
  76. package/lib/esm/shape/naming.d.ts +30 -0
  77. package/lib/esm/shape/naming.js +52 -0
  78. package/lib/esm/shape/naming.js.map +1 -0
  79. package/lib/esm/shape/nodeDisplay.d.ts +36 -0
  80. package/lib/esm/shape/nodeDisplay.js +117 -0
  81. package/lib/esm/shape/nodeDisplay.js.map +1 -0
  82. package/lib/esm/shape/propertyTypes.d.ts +12 -0
  83. package/lib/esm/shape/propertyTypes.js +60 -0
  84. package/lib/esm/shape/propertyTypes.js.map +1 -0
  85. package/lib/esm/shape/propertyVisibility.d.ts +56 -0
  86. package/lib/esm/shape/propertyVisibility.js +112 -0
  87. package/lib/esm/shape/propertyVisibility.js.map +1 -0
  88. package/lib/esm/shape/shapeDependencies.d.ts +41 -0
  89. package/lib/esm/shape/shapeDependencies.js +141 -0
  90. package/lib/esm/shape/shapeDependencies.js.map +1 -0
  91. package/lib/esm/shape/tableFilters.d.ts +35 -0
  92. package/lib/esm/shape/tableFilters.js +171 -0
  93. package/lib/esm/shape/tableFilters.js.map +1 -0
  94. package/lib/esm/shape/useFormDependencies.d.ts +38 -0
  95. package/lib/esm/shape/useFormDependencies.js +178 -0
  96. package/lib/esm/shape/useFormDependencies.js.map +1 -0
  97. package/lib/esm/shape/validation.d.ts +15 -0
  98. package/lib/esm/shape/validation.js +106 -0
  99. package/lib/esm/shape/validation.js.map +1 -0
  100. package/lib/esm/tokens.css +25 -0
  101. package/lib/esm/types.d.ts +22 -0
  102. package/lib/esm/types.js +1 -0
  103. package/lib/esm/types.js.map +1 -0
  104. package/lib/esm/write.d.ts +51 -0
  105. package/lib/esm/write.js +99 -0
  106. package/lib/esm/write.js.map +1 -0
  107. package/package.json +44 -18
  108. package/src/backend.ts +1 -0
  109. package/src/columns.regression.test.ts +143 -0
  110. package/src/columns.test.ts +200 -0
  111. package/src/columns.ts +228 -0
  112. package/src/components/AddInstanceForms.module.css +12 -0
  113. package/src/components/AddInstanceForms.tsx +321 -0
  114. package/src/components/AvatarEditor.tsx +34 -0
  115. package/src/components/BatchEditDrawer.module.css +193 -0
  116. package/src/components/BatchEditDrawer.tsx +295 -0
  117. package/src/components/CheckboxEditor.module.css +6 -0
  118. package/src/components/CheckboxEditor.tsx +86 -0
  119. package/src/components/CustomMultiSelect.module.css +291 -0
  120. package/src/components/CustomMultiSelect.tsx +527 -0
  121. package/src/components/DateEditor.tsx +50 -0
  122. package/src/components/DynamicFormField.module.css +169 -0
  123. package/src/components/DynamicFormField.tsx +357 -0
  124. package/src/components/EditInstanceForms.tsx +260 -0
  125. package/src/components/FilterBadge.module.css +25 -0
  126. package/src/components/FilterBadge.tsx +70 -0
  127. package/src/components/FormField.module.css +57 -0
  128. package/src/components/FormField.tsx +59 -0
  129. package/src/components/ImageValueEditor.module.css +3 -0
  130. package/src/components/ImageValueEditor.tsx +82 -0
  131. package/src/components/InstanceDeletionDialog.module.css +59 -0
  132. package/src/components/InstanceDeletionDialog.tsx +175 -0
  133. package/src/components/InstanceOverview.module.css +35 -0
  134. package/src/components/InstanceOverview.tsx +385 -0
  135. package/src/components/InstanceView.module.css +157 -0
  136. package/src/components/InstanceView.tsx +212 -0
  137. package/src/components/NodeBadge.tsx +60 -0
  138. package/src/components/NodeValuesEditor.module.css +4 -0
  139. package/src/components/NodeValuesEditor.tsx +64 -0
  140. package/src/components/RadioButtonEditor.module.css +22 -0
  141. package/src/components/RadioButtonEditor.tsx +96 -0
  142. package/src/components/ReactTable.module.css +950 -0
  143. package/src/components/ReactTable.tsx +1013 -0
  144. package/src/components/SelectEditor.tsx +85 -0
  145. package/src/components/ShapeTable.module.css +394 -0
  146. package/src/components/SwitchEditor.tsx +60 -0
  147. package/src/components/TextareaEditor.tsx +55 -0
  148. package/src/components/TextfieldEditor.tsx +67 -0
  149. package/src/components/ToggleEditor.tsx +63 -0
  150. package/src/components/forms.module.css +96 -0
  151. package/src/host.ts +226 -0
  152. package/src/hostContext.tsx +114 -0
  153. package/src/index.ts +93 -0
  154. package/src/package.ts +21 -0
  155. package/src/read.ts +148 -0
  156. package/src/search.test.ts +174 -0
  157. package/src/search.ts +108 -0
  158. package/src/shape/contracts.ts +66 -0
  159. package/src/shape/naming.ts +52 -0
  160. package/src/shape/nodeDisplay.ts +114 -0
  161. package/src/shape/propertyTypes.ts +86 -0
  162. package/src/shape/propertyVisibility.ts +180 -0
  163. package/src/shape/shapeDependencies.ts +183 -0
  164. package/src/shape/tableFilters.ts +223 -0
  165. package/src/shape/types.ts +1158 -0
  166. package/src/shape/useFormDependencies.ts +257 -0
  167. package/src/shape/validation.ts +121 -0
  168. package/src/tokens.css +25 -0
  169. package/src/types.ts +20 -0
  170. package/src/utils/helper.ts +35 -0
  171. package/src/write.test.ts +174 -0
  172. package/src/write.ts +114 -0
  173. package/.changeset/README.md +0 -20
  174. package/.changeset/config.json +0 -14
  175. package/.github/workflows/changeset-check.yml +0 -44
  176. package/.github/workflows/ci.yml +0 -43
  177. package/.github/workflows/publish.yml +0 -158
  178. package/CHANGELOG.md +0 -30
  179. package/lib/esm/components/AvatarEditor.d.ts +0 -20
  180. package/lib/esm/components/AvatarEditor.js +0 -7
  181. package/lib/esm/components/AvatarEditor.js.map +0 -1
  182. package/lib/esm/components/CheckboxEditor.d.ts +0 -21
  183. package/lib/esm/components/CheckboxEditor.js +0 -44
  184. package/lib/esm/components/CheckboxEditor.js.map +0 -1
  185. package/lib/esm/components/DateEditor.d.ts +0 -20
  186. package/lib/esm/components/DateEditor.js +0 -22
  187. package/lib/esm/components/DateEditor.js.map +0 -1
  188. package/lib/esm/components/RadioButtonEditor.d.ts +0 -23
  189. package/lib/esm/components/RadioButtonEditor.js +0 -41
  190. package/lib/esm/components/RadioButtonEditor.js.map +0 -1
  191. package/lib/esm/components/SelectEditor.d.ts +0 -21
  192. package/lib/esm/components/SelectEditor.js +0 -30
  193. package/lib/esm/components/SelectEditor.js.map +0 -1
  194. package/lib/esm/components/SwitchEditor.d.ts +0 -21
  195. package/lib/esm/components/SwitchEditor.js +0 -23
  196. package/lib/esm/components/SwitchEditor.js.map +0 -1
  197. package/lib/esm/components/TextareaEditor.d.ts +0 -23
  198. package/lib/esm/components/TextareaEditor.js +0 -15
  199. package/lib/esm/components/TextareaEditor.js.map +0 -1
  200. package/lib/esm/components/ToggleEditor.d.ts +0 -22
  201. package/lib/esm/components/ToggleEditor.js +0 -23
  202. package/lib/esm/components/ToggleEditor.js.map +0 -1
  203. package/lib/esm/ontologies/lincd-ui.d.ts +0 -6
  204. package/lib/esm/ontologies/lincd-ui.js +0 -18
  205. package/lib/esm/ontologies/lincd-ui.js.map +0 -1
  206. package/tsconfig-cjs.json +0 -8
  207. package/tsconfig-esm.json +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_linked/shape-ui",
3
- "description": "Shape-driven UI: components that take a shape and a property shape and render an editor for any shape, rather than a known one. Built on @_linked/primitives.",
3
+ "description": "UI driven by a shape: editors, a table over any shape's instances, a form derived from its properties, a read-only view and a relation picker — plus the read and write paths through the Linked Query DSL.",
4
4
  "author": {
5
5
  "name": "",
6
6
  "email": "",
@@ -8,18 +8,19 @@
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/linked-cm/ui.git"
11
+ "url": "git+https://github.com/linked-fw/shape-ui.git"
12
12
  },
13
- "version": "2.0.0",
13
+ "version": "2.3.0",
14
+ "type": "module",
14
15
  "linkedPackage": true,
15
- "main": "lib/index.js",
16
- "types": "dist/lincd-ui.d.ts",
16
+ "main": "lib/esm/index.js",
17
+ "types": "lib/esm/index.d.ts",
17
18
  "license": "ISC",
18
19
  "scripts": {
19
20
  "start": "npm exec linked dev",
20
21
  "build": "rimraf ./lib && yarn build-esm && yarn copy-to-lib",
21
- "build-esm": "echo \"compiling ESM\" && rimraf ./lib/esm && (yarn tsc -p tsconfig-esm.json || echo 'tsc returned non-zero; .js still emitted via noEmitOnError default')",
22
- "copy-to-lib": "echo 'copying CSS + JSON assets to lib folder' && yarn copyfiles -u 1 'src/**/*.css' 'src/**/*.json' lib/esm"
22
+ "build-esm": "echo \"compiling ESM\" && rimraf ./lib/esm && tsc -p tsconfig-esm.json",
23
+ "copy-to-lib": "echo 'copying CSS + JSON assets to lib folder' && copyfiles -u 1 'src/**/*.css' 'src/**/*.json' lib/esm"
23
24
  },
24
25
  "keywords": [
25
26
  "lincd",
@@ -29,19 +30,25 @@
29
30
  "web3"
30
31
  ],
31
32
  "dependencies": {
32
- "@_linked/core": "^2.0.1",
33
- "@_linked/primitives": "^1.0",
34
- "@_linked/react": "^1.2",
33
+ "@_linked/core": "^2.18.1",
34
+ "@_linked/react": "^1.4",
35
35
  "@_linked/schema": "^1.0",
36
- "@_linked/server": "^2.0"
36
+ "@tanstack/react-table": "^8.20.5"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@_linked/cli": "^1.2",
40
+ "@_linked/css": "^0.3.0",
41
+ "@_linked/icons": "^1.0.0",
42
+ "@_linked/primitives": "^1.2.0",
40
43
  "@capacitor/core": "^5.3.0",
41
44
  "@changesets/changelog-github": "^0.5.2",
42
45
  "@changesets/cli": "^2.29.8",
43
46
  "@types/node": "^20.14.9",
47
+ "@types/react": "^18.0.17",
44
48
  "copyfiles": "^2.4.1",
49
+ "framer-motion": "^12.0.0",
50
+ "react": "^18.2",
51
+ "react-dom": "^18.2",
45
52
  "rimraf": "^5.0.7",
46
53
  "tsconfig-to-dual-package": "^1.2.0",
47
54
  "typescript": "^5.7.3",
@@ -50,17 +57,24 @@
50
57
  "module": "lib/esm/index.js",
51
58
  "exports": {
52
59
  ".": {
60
+ "development": "./src/index.ts",
53
61
  "types": "./lib/esm/index.d.ts",
54
- "import": "./lib/esm/index.js"
62
+ "import": "./lib/esm/index.js",
63
+ "default": "./lib/esm/index.js"
55
64
  },
56
- "./*.js": {
57
- "types": "./lib/esm/*.d.ts",
58
- "import": "./lib/esm/*.js"
65
+ "./components/*": {
66
+ "development": "./src/components/*.tsx",
67
+ "types": "./lib/esm/components/*.d.ts",
68
+ "import": "./lib/esm/components/*.js",
69
+ "default": "./lib/esm/components/*.js"
59
70
  },
60
71
  "./*": {
72
+ "development": "./src/*.ts",
61
73
  "types": "./lib/esm/*.d.ts",
62
- "import": "./lib/esm/*.js"
63
- }
74
+ "import": "./lib/esm/*.js",
75
+ "default": "./lib/esm/*.js"
76
+ },
77
+ "./tokens.css": "./src/tokens.css"
64
78
  },
65
79
  "typesVersions": {
66
80
  "*": {
@@ -71,5 +85,17 @@
71
85
  },
72
86
  "publishConfig": {
73
87
  "access": "public"
74
- }
88
+ },
89
+ "peerDependencies": {
90
+ "@_linked/css": "^0.3.0",
91
+ "@_linked/icons": "^1.0.0",
92
+ "@_linked/primitives": "^1.2.0",
93
+ "framer-motion": ">=11",
94
+ "react": "^18 || ^19"
95
+ },
96
+ "files": [
97
+ "lib",
98
+ "src",
99
+ "README.md"
100
+ ]
75
101
  }
package/src/backend.ts ADDED
@@ -0,0 +1 @@
1
+ //import your providers here (providers only run in the backend)
@@ -0,0 +1,143 @@
1
+ import {describe, expect, test} from '@jest/globals';
2
+ import type {
3
+ NodeShapeWire,
4
+ PropertyShapeWire,
5
+ } from '@_linked/core/shapes/nodeShapeWire';
6
+ import {columnsFor, displayableProperties} from './columns.js';
7
+
8
+ /**
9
+ * Regression guard for review finding 13.
10
+ *
11
+ * The first version of `columnsFor` treated every context like a narrow cell: a property
12
+ * with a `valueShape`, a `maxCount` other than 1, or a long-text datatype was excluded
13
+ * outright. Wired into the CMS table that silently deleted every relation column (author,
14
+ * category, tags — the ones `NodeBadge` and the click-through exist to render), every
15
+ * multi-valued column, and every long-text column.
16
+ *
17
+ * The rule these tests pin: **what fits depends on how much room there is.** A cell shows
18
+ * one short scalar. A table can show a badge for a related node and a list of them. `full`
19
+ * shows everything the shape does not hide.
20
+ */
21
+
22
+ const base = 'https://example.org/';
23
+
24
+ function prop(
25
+ label: string,
26
+ extra: Partial<PropertyShapeWire> = {},
27
+ ): PropertyShapeWire {
28
+ return {
29
+ id: `${base}shape/Article/${label}`,
30
+ label,
31
+ path: {id: `${base}vocab#${label}`},
32
+ ...extra,
33
+ } as PropertyShapeWire;
34
+ }
35
+
36
+ const article = (): NodeShapeWire =>
37
+ ({
38
+ id: `${base}shape/Article`,
39
+ label: 'Article',
40
+ targetClass: {id: `${base}vocab#Article`},
41
+ propertyShapes: [
42
+ prop('title'),
43
+ prop('author', {valueShape: {id: `${base}shape/Person`}}),
44
+ prop('category', {class: {id: `${base}vocab#Category`}}),
45
+ prop('tags', {maxCount: 0}),
46
+ prop('body', {
47
+ datatype: {id: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML'},
48
+ }),
49
+ prop('status', {in: [{id: `${base}vocab#Draft`}, {id: `${base}vocab#Live`}]}),
50
+ prop('internal', {displayHidden: true}),
51
+ ],
52
+ }) as NodeShapeWire;
53
+
54
+ describe('finding 13 — a table keeps the columns a cell cannot hold', () => {
55
+ test('relation columns survive in a table', () => {
56
+ const keys = columnsFor(article(), 'table', Infinity).map((c) => c.key);
57
+ expect(keys).toContain('author'); // sh:node
58
+ expect(keys).toContain('category'); // sh:class
59
+ });
60
+
61
+ test('multi-valued columns survive in a table', () => {
62
+ expect(columnsFor(article(), 'table', Infinity).map((c) => c.key)).toContain(
63
+ 'tags',
64
+ );
65
+ });
66
+
67
+ test('long-text columns survive in `full`, which is what a detail view uses', () => {
68
+ expect(columnsFor(article(), 'full').map((c) => c.key)).toContain('body');
69
+ });
70
+
71
+ test('enum columns survive', () => {
72
+ expect(columnsFor(article(), 'table', Infinity).map((c) => c.key)).toContain(
73
+ 'status',
74
+ );
75
+ });
76
+
77
+ test('a hidden property is still excluded everywhere', () => {
78
+ for (const context of ['cell', 'card', 'table', 'full'] as const) {
79
+ expect(columnsFor(article(), context, Infinity).map((c) => c.key)).not.toContain(
80
+ 'internal',
81
+ );
82
+ }
83
+ });
84
+
85
+ test('a cell still shows one short scalar, not a relation or a blob', () => {
86
+ // The narrow case is the one the original rule was written for, and it stays.
87
+ const cell = columnsFor(article(), 'cell');
88
+ expect(cell).toHaveLength(1);
89
+ expect(cell[0].key).toBe('title');
90
+ });
91
+
92
+ test('a cell-sized label is never a relation, even when nothing else qualifies', () => {
93
+ const relationsOnly = {
94
+ id: `${base}shape/Only`,
95
+ label: 'Only',
96
+ targetClass: {id: `${base}vocab#Only`},
97
+ propertyShapes: [prop('author', {valueShape: {id: `${base}shape/Person`}})],
98
+ } as NodeShapeWire;
99
+ expect(columnsFor(relationsOnly, 'cell')).toHaveLength(0);
100
+ });
101
+
102
+ test('displayableProperties is context-aware, not one fixed answer', () => {
103
+ // An enum is a short scalar, so it fits a cell too.
104
+ expect(displayableProperties(article(), 'cell').map((p) => p.label)).toEqual([
105
+ 'title',
106
+ 'status',
107
+ ]);
108
+ expect(displayableProperties(article(), 'full').map((p) => p.label)).toEqual([
109
+ 'title',
110
+ 'author',
111
+ 'category',
112
+ 'tags',
113
+ 'body',
114
+ 'status',
115
+ ]);
116
+ });
117
+ });
118
+
119
+ describe('finding 16 — the declared comparator is well defined', () => {
120
+ test('two properties that declare nothing compare as equal, not NaN', () => {
121
+ // `Infinity - Infinity` is NaN, which makes a comparator inconsistent. V8 happens to
122
+ // treat NaN as "equal", so this passed by luck rather than by definition.
123
+ const shape = {
124
+ id: `${base}shape/Plain`,
125
+ label: 'Plain',
126
+ targetClass: {id: `${base}vocab#Plain`},
127
+ propertyShapes: [prop('b'), prop('a')],
128
+ } as NodeShapeWire;
129
+ const keys = columnsFor(shape, 'full').map((c) => c.key);
130
+ expect(keys).toHaveLength(2);
131
+ expect(keys).toEqual(expect.arrayContaining(['a', 'b']));
132
+ });
133
+
134
+ test('a declared rank still beats an undeclared one', () => {
135
+ const shape = {
136
+ id: `${base}shape/Mixed`,
137
+ label: 'Mixed',
138
+ targetClass: {id: `${base}vocab#Mixed`},
139
+ propertyShapes: [prop('plain'), prop('ranked', {displayRank: 5})],
140
+ } as NodeShapeWire;
141
+ expect(columnsFor(shape, 'full').map((c) => c.key)).toEqual(['ranked', 'plain']);
142
+ });
143
+ });
@@ -0,0 +1,200 @@
1
+ import {describe, expect, test} from '@jest/globals';
2
+ import type {
3
+ NodeShapeWire,
4
+ PropertyShapeWire,
5
+ } from '@_linked/core/shapes/nodeShapeWire';
6
+ import {
7
+ columnsFor,
8
+ displayableProperties,
9
+ hasDeclaredRanks,
10
+ labelProperty,
11
+ } from './columns.js';
12
+
13
+ const base = 'https://example.org/';
14
+
15
+ function prop(
16
+ label: string,
17
+ extra: Partial<PropertyShapeWire> = {},
18
+ ): PropertyShapeWire {
19
+ return {
20
+ id: `${base}shape/Thing/${label}`,
21
+ label,
22
+ path: {id: `${base}vocab#${label}`},
23
+ ...extra,
24
+ } as PropertyShapeWire;
25
+ }
26
+
27
+ function shape(props: PropertyShapeWire[]): NodeShapeWire {
28
+ return {
29
+ id: `${base}shape/Thing`,
30
+ label: 'Thing',
31
+ targetClass: {id: `${base}vocab#Thing`},
32
+ propertyShapes: props,
33
+ } as NodeShapeWire;
34
+ }
35
+
36
+ describe('declared metadata wins', () => {
37
+ test('displayRank orders the columns', () => {
38
+ const s = shape([
39
+ prop('zeta', {displayRank: 3}),
40
+ prop('alpha', {displayRank: 1}),
41
+ prop('beta', {displayRank: 2}),
42
+ ]);
43
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual([
44
+ 'alpha',
45
+ 'beta',
46
+ 'zeta',
47
+ ]);
48
+ });
49
+
50
+ test('the lowest declared rank is the label column', () => {
51
+ const s = shape([prop('sku', {displayRank: 2}), prop('title', {displayRank: 1})]);
52
+ expect(labelProperty(s)?.label).toBe('title');
53
+ expect(columnsFor(s, 'full')[0]).toMatchObject({key: 'title', isLabel: true});
54
+ });
55
+
56
+ test('displayRank beats a conventional label predicate', () => {
57
+ // rdfs:label would otherwise win outright. An explicit rank is a first-party
58
+ // statement and has to outrank a convention.
59
+ const s = shape([
60
+ prop('name', {path: {id: 'http://www.w3.org/2000/01/rdf-schema#label'}}),
61
+ prop('code', {displayRank: 1}),
62
+ ]);
63
+ expect(labelProperty(s)?.label).toBe('code');
64
+ });
65
+
66
+ test('displayHidden removes a property entirely, even a required one', () => {
67
+ const s = shape([
68
+ prop('secret', {displayHidden: true, minCount: 1}),
69
+ prop('title'),
70
+ ]);
71
+ expect(displayableProperties(s).map((p) => p.label)).toEqual(['title']);
72
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual(['title']);
73
+ });
74
+
75
+ test('sh:order breaks ties between equally ranked properties', () => {
76
+ const s = shape([
77
+ prop('b', {displayRank: 1, order: 2}),
78
+ prop('a', {displayRank: 1, order: 1}),
79
+ ]);
80
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual(['a', 'b']);
81
+ });
82
+
83
+ test('unranked properties sort after ranked ones', () => {
84
+ const s = shape([prop('plain'), prop('ranked', {displayRank: 1})]);
85
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual(['ranked', 'plain']);
86
+ });
87
+ });
88
+
89
+ describe('heuristics, only where nothing is declared', () => {
90
+ test('a conventional label predicate is the label', () => {
91
+ const s = shape([
92
+ prop('sku'),
93
+ prop('title', {path: {id: 'http://schema.org/name'}}),
94
+ ]);
95
+ expect(labelProperty(s)?.label).toBe('title');
96
+ });
97
+
98
+ test('a name-ish property is the label when no convention matches', () => {
99
+ const s = shape([prop('quantity'), prop('productTitle')]);
100
+ expect(labelProperty(s)?.label).toBe('productTitle');
101
+ });
102
+
103
+ test('required properties outrank optional ones', () => {
104
+ const s = shape([prop('optional'), prop('mandatory', {minCount: 1})]);
105
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual([
106
+ 'mandatory',
107
+ 'optional',
108
+ ]);
109
+ });
110
+
111
+ test('hasDeclaredRanks reports which regime applies', () => {
112
+ expect(hasDeclaredRanks(shape([prop('a')]))).toBe(false);
113
+ expect(hasDeclaredRanks(shape([prop('a', {displayRank: 1})]))).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe('what cannot go in a cell', () => {
118
+ // These assert the CELL rule specifically. They used to assert it at `full`, which
119
+ // encoded the bug in review finding 13: applying the narrowest rule everywhere deleted
120
+ // relation, multi-valued and long-text columns from tables too.
121
+ test('a related node is not a cell', () => {
122
+ const s = shape([
123
+ prop('title'),
124
+ prop('author', {valueShape: {id: `${base}shape/Person`}}),
125
+ ]);
126
+ expect(columnsFor(s, 'cell').map((c) => c.key)).toEqual(['title']);
127
+ // …but a table shows it.
128
+ expect(columnsFor(s, 'table').map((c) => c.key)).toContain('author');
129
+ });
130
+
131
+ test('an unbounded property is not a cell — a cell cannot show a list', () => {
132
+ const s = shape([prop('title'), prop('tags', {maxCount: 0})]);
133
+ expect(columnsFor(s, 'cell').map((c) => c.key)).toEqual(['title']);
134
+ expect(columnsFor(s, 'table').map((c) => c.key)).toContain('tags');
135
+ });
136
+
137
+ test('long-text datatypes are excluded from every context but `full`', () => {
138
+ const s = shape([
139
+ prop('title'),
140
+ prop('body', {
141
+ datatype: {id: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML'},
142
+ }),
143
+ ]);
144
+ expect(columnsFor(s, 'table').map((c) => c.key)).toEqual(['title']);
145
+ expect(columnsFor(s, 'full').map((c) => c.key)).toContain('body');
146
+ });
147
+
148
+ test('a property with no label is skipped — the row key would be undefined', () => {
149
+ const s = shape([prop('title'), {...prop('x'), label: ''} as PropertyShapeWire]);
150
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual(['title']);
151
+ });
152
+ });
153
+
154
+ describe('context limits', () => {
155
+ const many = shape(
156
+ ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((l, i) =>
157
+ prop(l, {displayRank: i + 1}),
158
+ ),
159
+ );
160
+
161
+ test('each context takes the top N by declared precedence', () => {
162
+ expect(columnsFor(many, 'cell')).toHaveLength(1);
163
+ expect(columnsFor(many, 'card')).toHaveLength(3);
164
+ expect(columnsFor(many, 'table')).toHaveLength(6);
165
+ expect(columnsFor(many, 'full')).toHaveLength(8);
166
+ });
167
+
168
+ test('the cell context keeps the label', () => {
169
+ expect(columnsFor(many, 'cell')[0]).toMatchObject({key: 'a', isLabel: true});
170
+ });
171
+
172
+ test('an explicit limit overrides the context', () => {
173
+ expect(columnsFor(many, 'table', 2).map((c) => c.key)).toEqual(['a', 'b']);
174
+ });
175
+ });
176
+
177
+ describe('degenerate shapes', () => {
178
+ test('a shape with no properties yields no columns and no label', () => {
179
+ const s = shape([]);
180
+ expect(columnsFor(s, 'full')).toEqual([]);
181
+ expect(labelProperty(s)).toBeUndefined();
182
+ });
183
+
184
+ test('a shape with nothing showable yields no label rather than guessing', () => {
185
+ const s = shape([prop('author', {valueShape: {id: `${base}shape/Person`}})]);
186
+ expect(labelProperty(s)).toBeUndefined();
187
+ });
188
+
189
+ test('a complex path is still a column — it just has no single predicate', () => {
190
+ const s = shape([
191
+ prop('city', {path: {seq: [{id: `${base}vocab#address`}, {id: `${base}vocab#city`}]} as never}),
192
+ ]);
193
+ expect(columnsFor(s, 'full').map((c) => c.key)).toEqual(['city']);
194
+ });
195
+
196
+ test('header prefers sh:name over the label key', () => {
197
+ const s = shape([prop('givenName', {name: 'First name'})]);
198
+ expect(columnsFor(s, 'full')[0].header).toBe('First name');
199
+ });
200
+ });
package/src/columns.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The table model: a shape in, ordered and filtered columns out.
3
+ *
4
+ * Pure and framework-free on purpose. Which columns a table shows, in what order, and
5
+ * which one carries the label is the part worth testing and the part that has to behave
6
+ * identically wherever the table renders. Keeping it out of the React layer means it can
7
+ * be exercised directly, and means a different renderer (a mobile list, a picker, a
8
+ * printed view) makes the same decisions.
9
+ *
10
+ * The rule throughout: **what the shape declares beats what the renderer guesses.**
11
+ * `linked_core:displayRank` orders, `linked_core:displayHidden` removes, `sh:order`
12
+ * breaks ties. The heuristics apply only where a shape declares nothing — which, until
13
+ * the display vocabulary existed, was everywhere.
14
+ */
15
+
16
+ import type {
17
+ NodeShapeWire,
18
+ PropertyShapeWire,
19
+ } from '@_linked/core/shapes/nodeShapeWire';
20
+
21
+ /**
22
+ * Whether the table shows the columns it judges useful, or every column.
23
+ *
24
+ * Lives here rather than in a page. `InstanceOverview` and `ReactTable` both imported this
25
+ * type from `pages/InstanceOverviewPage` — organisms depending on a page, which is exactly
26
+ * backwards and would have blocked moving either of them.
27
+ */
28
+ export type TableMode = 'auto' | 'full';
29
+
30
+ /** How much room the caller has. A table shows more than a card; a cell shows one. */
31
+ export type DisplayContext = 'cell' | 'card' | 'table' | 'full';
32
+
33
+ const CONTEXT_LIMITS: Record<DisplayContext, number> = {
34
+ cell: 1,
35
+ card: 3,
36
+ table: 6,
37
+ full: Number.POSITIVE_INFINITY,
38
+ };
39
+
40
+ /** Predicates that conventionally carry a human label, best first. */
41
+ const LABEL_PREDICATES = [
42
+ 'http://www.w3.org/2000/01/rdf-schema#label',
43
+ 'http://www.w3.org/2004/02/skos/core#prefLabel',
44
+ 'http://schema.org/name',
45
+ 'http://purl.org/dc/terms/title',
46
+ 'http://purl.org/dc/elements/1.1/title',
47
+ 'http://xmlns.com/foaf/0.1/name',
48
+ ];
49
+
50
+ /** Names that read as an identity rather than an attribute. */
51
+ const NAMEY = /(name|label|title|identifier|handle|slug|heading|caption)/i;
52
+
53
+ /** Datatypes whose values are too long to sit in a table cell. */
54
+ const LONG_TEXT = new Set([
55
+ 'http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML',
56
+ 'http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral',
57
+ ]);
58
+
59
+ export interface Column {
60
+ /** The property this column reads. */
61
+ property: PropertyShapeWire;
62
+ /** The key a row object is expected to carry — the property's label. */
63
+ key: string;
64
+ /** What to put in the header. */
65
+ header: string;
66
+ /** True for the column that best identifies the row. */
67
+ isLabel: boolean;
68
+ }
69
+
70
+ /** The predicate IRI of a simple path, or undefined for a complex one. */
71
+ function predicateOf(property: PropertyShapeWire): string | undefined {
72
+ const path = property.path as unknown;
73
+ if (typeof path === 'string') return path;
74
+ if (path && typeof path === 'object' && 'id' in (path as {id?: string})) {
75
+ return (path as {id: string}).id;
76
+ }
77
+ return undefined; // sequence / alternative / inverse — not a single predicate
78
+ }
79
+
80
+ function localName(iri: string): string {
81
+ const cut = Math.max(iri.lastIndexOf('#'), iri.lastIndexOf('/'));
82
+ return cut >= 0 ? iri.slice(cut + 1) : iri;
83
+ }
84
+
85
+ /**
86
+ * Whether a property can be shown at a given context.
87
+ *
88
+ * What fits depends on how much room there is, and an earlier version of this got that
89
+ * wrong: it applied the narrowest rule everywhere, so wiring it into a table silently
90
+ * deleted every relation, multi-valued and long-text column. A cell really can only hold
91
+ * one short scalar. A table can hold a badge for a related node, and a row of them.
92
+ */
93
+ function fitsContext(
94
+ property: PropertyShapeWire,
95
+ context: DisplayContext,
96
+ ): boolean {
97
+ const isRelation = !!(property.valueShape || property.class);
98
+ const isMultiValued = property.maxCount != null && property.maxCount !== 1;
99
+ const isLongText = !!property.datatype && LONG_TEXT.has(property.datatype.id);
100
+
101
+ switch (context) {
102
+ case 'cell':
103
+ // One value, rendered inline. A relation would need a badge and a long text would
104
+ // need truncation; neither belongs in a cell that stands in for the whole instance.
105
+ return !isRelation && !isMultiValued && !isLongText;
106
+ case 'card':
107
+ // Room for a few values including a related node, but still not a wall of text.
108
+ return !isLongText;
109
+ case 'table':
110
+ // A table renders relations as badges and multi-values as a row of them. Long text
111
+ // is the one thing that breaks a row's height.
112
+ return !isLongText;
113
+ case 'full':
114
+ // A detail view shows everything the shape does not hide.
115
+ return true;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Sort key for an undeclared value. A finite sentinel rather than `Infinity`, because
121
+ * `Infinity - Infinity` is `NaN` and a comparator that returns NaN is undefined by spec —
122
+ * it happened to behave because V8 reads NaN as "equal".
123
+ */
124
+ const UNDECLARED = Number.MAX_SAFE_INTEGER;
125
+
126
+ const declaredRank = (p: PropertyShapeWire): number => p.displayRank ?? UNDECLARED;
127
+ const declaredOrder = (p: PropertyShapeWire): number => p.order ?? UNDECLARED;
128
+
129
+ /** Declared precedence: rank, then `sh:order`. Undeclared properties sort last. */
130
+ function byDeclared(a: PropertyShapeWire, b: PropertyShapeWire): number {
131
+ return declaredRank(a) - declaredRank(b) || declaredOrder(a) - declaredOrder(b);
132
+ }
133
+
134
+ /**
135
+ * Heuristic score, used only where nothing is declared. Higher is more important.
136
+ *
137
+ * These weights are a guess dressed as a number, and they exist so a shape that says
138
+ * nothing still renders sensibly. A shape that declares `displayRank` never reaches here.
139
+ */
140
+ function score(property: PropertyShapeWire): number {
141
+ let s = 0;
142
+ if ((property.minCount ?? 0) > 0) s += 100;
143
+ const name = `${property.label ?? ''} ${localName(predicateOf(property) ?? '')}`;
144
+ if (NAMEY.test(name)) s += 50;
145
+ if (/(type|description|date|email|url)/i.test(name)) s += 25;
146
+ if (property.datatype) s += 10;
147
+ return s;
148
+ }
149
+
150
+ /**
151
+ * Properties the shape does not hide, and that fit the given context.
152
+ *
153
+ * `displayHidden` is checked first and applies to every context: it is a statement about
154
+ * the property, not about how much room there is.
155
+ */
156
+ export function displayableProperties(
157
+ shape: NodeShapeWire,
158
+ context: DisplayContext = 'table',
159
+ ): PropertyShapeWire[] {
160
+ return (shape.propertyShapes ?? []).filter(
161
+ (p) => p.displayHidden !== true && !!p.label && fitsContext(p, context),
162
+ );
163
+ }
164
+
165
+ /** True when any property on the shape declares a rank. */
166
+ export function hasDeclaredRanks(shape: NodeShapeWire): boolean {
167
+ return (shape.propertyShapes ?? []).some((p) => p.displayRank != null);
168
+ }
169
+
170
+ /**
171
+ * The property that best identifies an instance of this shape.
172
+ *
173
+ * Declared rank first; then a conventional label predicate; then a name-ish literal.
174
+ * Undefined when the shape has nothing showable — the caller falls back to the IRI, which
175
+ * is a display decision and not this function's to make.
176
+ */
177
+ export function labelProperty(
178
+ shape: NodeShapeWire,
179
+ context: DisplayContext = 'cell',
180
+ ): PropertyShapeWire | undefined {
181
+ // The label stands in for the whole instance, so it is held to the CELL rule even when
182
+ // the surrounding table is wider — a relation or a blob does not identify a row.
183
+ const candidates = displayableProperties(shape, context);
184
+ if (candidates.length === 0) return undefined;
185
+
186
+ if (hasDeclaredRanks(shape)) {
187
+ const ranked = candidates
188
+ .filter((p) => p.displayRank != null)
189
+ .sort(byDeclared);
190
+ if (ranked.length) return ranked[0];
191
+ }
192
+
193
+ for (const predicate of LABEL_PREDICATES) {
194
+ const match = candidates.find((p) => predicateOf(p) === predicate);
195
+ if (match) return match;
196
+ }
197
+
198
+ return candidates.find((p) =>
199
+ NAMEY.test(`${p.label} ${localName(predicateOf(p) ?? '')}`),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Columns for a shape at a given context, label first.
205
+ *
206
+ * `limit` overrides the context's own limit for a caller that knows its space better.
207
+ */
208
+ export function columnsFor(
209
+ shape: NodeShapeWire,
210
+ context: DisplayContext = 'table',
211
+ limit?: number,
212
+ ): Column[] {
213
+ const label = labelProperty(shape);
214
+ const declared = hasDeclaredRanks(shape);
215
+
216
+ const rest = displayableProperties(shape, context).filter((p) => p !== label);
217
+ rest.sort(declared ? byDeclared : (a, b) => score(b) - score(a));
218
+
219
+ const ordered = label ? [label, ...rest] : rest;
220
+ const max = limit ?? CONTEXT_LIMITS[context];
221
+
222
+ return ordered.slice(0, max).map((property) => ({
223
+ property,
224
+ key: property.label,
225
+ header: property.name || property.label,
226
+ isLabel: property === label,
227
+ }));
228
+ }