sanity-plugin-taxonomy-manager 4.7.2 → 5.0.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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +13 -108
  3. package/{lib → dist}/index.d.ts +66 -48
  4. package/dist/index.js +106 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +40 -46
  7. package/src/components/Children.tsx +4 -3
  8. package/src/components/Concepts.tsx +2 -2
  9. package/src/components/Hierarchy.test.tsx +92 -0
  10. package/src/components/Hierarchy.tsx +65 -30
  11. package/src/components/TopConcepts.tsx +2 -2
  12. package/src/components/TreeStructure.tsx +3 -3
  13. package/src/components/inputs/ArrayHierarchyInput.test.tsx +102 -0
  14. package/src/components/inputs/ArrayHierarchyInput.tsx +23 -24
  15. package/src/components/inputs/Identifier.tsx +1 -1
  16. package/src/components/inputs/InputHierarchy.test.tsx +38 -0
  17. package/src/components/inputs/InputHierarchy.tsx +20 -68
  18. package/src/components/inputs/ReferenceHierarchyInput.test.tsx +109 -0
  19. package/src/components/inputs/ReferenceHierarchyInput.tsx +24 -25
  20. package/src/components/interactions/ConceptDetailLink.tsx +0 -1
  21. package/src/components/interactions/ConceptEditAction.tsx +1 -1
  22. package/src/components/interactions/ConceptSelectLink.test.tsx +49 -0
  23. package/src/components/interactions/ConceptSelectLink.tsx +10 -6
  24. package/src/context.ts +34 -14
  25. package/src/core/__snapshots__/queries.test.ts.snap +386 -0
  26. package/src/core/__snapshots__/semanticRecommendations.test.ts.snap +10 -0
  27. package/src/core/createId.test.ts +35 -0
  28. package/src/core/filters.test.ts +64 -0
  29. package/src/core/filters.ts +68 -0
  30. package/src/core/ids.test.ts +68 -0
  31. package/src/core/ids.ts +61 -0
  32. package/src/core/mutations.test.ts +114 -0
  33. package/src/core/mutations.ts +105 -0
  34. package/src/core/ports.ts +59 -0
  35. package/src/core/queries.test.ts +50 -0
  36. package/src/{queries.ts → core/queries.ts} +7 -6
  37. package/src/core/semanticRecommendations.test.ts +160 -0
  38. package/src/core/semanticRecommendations.ts +127 -0
  39. package/src/core/tree/annotateRecommendations.test.ts +43 -0
  40. package/src/core/tree/annotateRecommendations.ts +30 -0
  41. package/src/core/tree/pruneConcepts.test.ts +77 -0
  42. package/src/core/tree/pruneConcepts.ts +72 -0
  43. package/src/core/validation.test.ts +42 -0
  44. package/src/core/validation.ts +42 -0
  45. package/src/helpers/baseIriField.ts +22 -0
  46. package/src/helpers/branchFilter.test.ts +48 -0
  47. package/src/helpers/branchFilter.ts +17 -26
  48. package/src/helpers/schemeFilter.test.ts +51 -0
  49. package/src/helpers/schemeFilter.ts +11 -23
  50. package/src/hooks/index.ts +1 -1
  51. package/src/hooks/useCreateConcept.tsx +33 -110
  52. package/src/hooks/useRemoveConcept.tsx +24 -36
  53. package/src/hooks/useSemanticRecommendations.tsx +75 -0
  54. package/src/index.test.ts +31 -0
  55. package/src/index.ts +2 -6
  56. package/src/seams/StudioDataAdapter.ts +81 -0
  57. package/src/seams/TaxonomyPortContext.tsx +33 -0
  58. package/src/skosConcept.tsx +23 -234
  59. package/src/skosConceptScheme.tsx +2 -4
  60. package/src/structure.tsx +60 -0
  61. package/src/test/FakeDataPort.test.ts +62 -0
  62. package/src/test/FakeDataPort.ts +69 -0
  63. package/src/test/inputHarness.tsx +71 -0
  64. package/src/test/renderWithUi.test.tsx +14 -0
  65. package/src/test/renderWithUi.tsx +24 -0
  66. package/src/test-setup.ts +35 -0
  67. package/src/types.tsx +30 -11
  68. package/src/views/ConceptUseView.tsx +4 -10
  69. package/lib/index.esm.d.mts +0 -492
  70. package/lib/index.esm.esm.js +0 -182
  71. package/lib/index.esm.esm.js.map +0 -1
  72. package/lib/index.esm.mjs +0 -182
  73. package/lib/index.esm.mjs.map +0 -1
  74. package/lib/index.js +0 -182
  75. package/lib/index.js.map +0 -1
  76. package/sanity.json +0 -8
  77. package/src/config.ts +0 -16
  78. package/src/helpers/baseIriField.tsx +0 -42
  79. package/src/hooks/useEmbeddingsRecs.tsx +0 -75
  80. package/src/skosConcept.module.css +0 -10
  81. package/src/structure.ts +0 -40
  82. package/v2-incompatible.js +0 -11
  83. /package/src/{helpers → core}/createId.ts +0 -0
@@ -1,38 +1,19 @@
1
- import {WarningOutlineIcon} from '@sanity/icons'
2
- import {DocumentId, getPublishedId} from '@sanity/id-utils'
3
1
  import {AiOutlineTag, AiOutlineTags} from 'react-icons/ai'
4
2
  import {defineType, defineField} from 'sanity'
5
- import type {SanityDocument} from 'sanity'
6
3
 
7
4
  import {Identifier} from './components/inputs'
5
+ import {createId} from './core/createId'
6
+ import {conceptFilter, prefLabelUniquenessResult} from './core/validation'
8
7
  import baseIriField from './helpers/baseIriField'
9
- import {createId} from './helpers/createId'
10
- import styles from './skosConcept.module.css'
11
8
  import type {Options} from './types'
12
9
 
13
- const conceptFilter = ({document}: {document: SanityDocument}) => {
14
- const publishedId = getPublishedId(DocumentId(document._id))
15
- return {
16
- filter: '!(_id in $broader || _id in $related || _id == $self)',
17
- params: {
18
- self: publishedId,
19
- broader: Array.isArray(document?.broader)
20
- ? document.broader.map(({_ref}: {_ref: string}) => _ref)
21
- : [],
22
- related: Array.isArray(document?.related)
23
- ? document.related.map(({_ref}: {_ref: string}) => _ref)
24
- : [],
25
- },
26
- }
27
- }
28
-
29
10
  /**
30
11
  * Sanity document scheme for SKOS Taxonomy Concepts
31
12
  */
32
13
  export default function skosConcept(
33
14
  baseUri?: Options['baseUri'],
34
15
  customConceptFields: Options['customConceptFields'] = [],
35
- ident?: Options['ident']
16
+ ident?: Options['ident'],
36
17
  ) {
37
18
  return defineType({
38
19
  name: 'skosConcept',
@@ -73,125 +54,40 @@ export default function skosConcept(
73
54
  .fetch(
74
55
  `*[_type == "skosConcept" && prefLabel == "${
75
56
  prefLabel as string
76
- }" && !(_id in path("drafts.**") || _id in path("versions.**"))][0]._id`
57
+ }" && !(_id in path("drafts.**") || _id in path("versions.**"))][0]._id`,
58
+ )
59
+ .then((conceptId) =>
60
+ prefLabelUniquenessResult(conceptId as string | null, context.document?._id),
77
61
  )
78
- .then((conceptId) => {
79
- if (conceptId && !context.document?._id.includes(conceptId as string)) {
80
- return 'Preferred Label must be unique.'
81
- }
82
- return true
83
- })
84
62
  }),
85
63
  }),
86
64
  defineField({
87
65
  name: 'definition',
88
66
  title: 'Definition',
89
67
  type: 'text',
90
- description: (
91
- <details className={styles.description}>
92
- <summary>A complete explanation of the intended meaning of the concept.</summary>
93
- <div>
94
- <kbd>
95
- Example: documentation
96
- <br />
97
- Definition: "The process of storing and retrieving information in all fields of
98
- knowledge."
99
- </kbd>
100
- <p>
101
- For more information on the recommended usage of the SKOS documentation properties,
102
- see
103
- <a
104
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
105
- target="_blank"
106
- rel="noreferrer"
107
- >
108
- {' '}
109
- W3C SKOS Primer: 2.4 Documentary Notes
110
- </a>
111
- </p>
112
- </div>
113
- </details>
114
- ),
68
+ description: 'A complete explanation of the intended meaning of the concept.',
115
69
  rows: 3,
116
70
  }),
117
71
  defineField({
118
72
  name: 'example',
119
73
  title: 'Examples',
120
74
  type: 'text',
121
- description: (
122
- <details className={styles.description}>
123
- <summary>An example of the use of the concept.</summary>
124
- <div>
125
- <kbd>
126
- Example: organizations of science and culture
127
- <br />
128
- Example: "academies of science, general museums, world fairs"
129
- </kbd>
130
- <p>
131
- For more information on the recommended usage of the SKOS documentation properties,
132
- see
133
- <a
134
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
135
- target="_blank"
136
- rel="noreferrer"
137
- >
138
- {' '}
139
- W3C SKOS Primer: 2.4 Documentary Notes
140
- </a>
141
- </p>
142
- </div>
143
- </details>
144
- ),
75
+ description: 'An example of the use of the concept.',
145
76
  rows: 3,
146
77
  }),
147
78
  defineField({
148
79
  name: 'scopeNote',
149
80
  title: 'Scope Note',
150
81
  type: 'text',
151
- description: (
152
- <details className={styles.description}>
153
- <summary>
154
- A brief statement on the intended meaning of this concept, especially as an indication
155
- of how the use of the concept is limited in indexing practice.
156
- </summary>
157
- <div>
158
- <kbd>
159
- Example: microwave frequencies
160
- <br />
161
- Scope Note: "Used for frequencies between 1Ghz and 300Ghz"
162
- </kbd>
163
- <p>
164
- For more information on the recommended usage of the SKOS documentation properties,
165
- see
166
- <a
167
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
168
- target="_blank"
169
- rel="noreferrer"
170
- >
171
- {' '}
172
- W3C SKOS Primer: 2.4 Documentary Notes
173
- </a>
174
- </p>
175
- </div>
176
- </details>
177
- ),
82
+ description:
83
+ 'A brief statement on the intended meaning of this concept, especially as an indication of how the use of the concept is limited in indexing practice.',
178
84
  rows: 3,
179
85
  }),
180
86
  defineField({
181
87
  name: 'altLabel',
182
88
  title: 'Alternate Label(s)',
183
89
  type: 'array',
184
- description: (
185
- <details className={styles.description}>
186
- <summary>Synonyms, near-synonyms, abbreviations, and acronyms to a concept.</summary>
187
- <div>
188
- <p>
189
- <WarningOutlineIcon /> Preferred, alternative, and hidden label sets must not
190
- overlap.
191
- </p>
192
- </div>
193
- </details>
194
- ),
90
+ description: 'Synonyms, near-synonyms, abbreviations, and acronyms to a concept.',
195
91
  of: [{type: 'string'}],
196
92
  validation: (Rule) => Rule.unique(),
197
93
  }),
@@ -199,24 +95,8 @@ export default function skosConcept(
199
95
  name: 'hiddenLabel',
200
96
  title: 'Hidden Label(s)',
201
97
  type: 'array',
202
- description: (
203
- <details className={styles.description}>
204
- <summary>
205
- Character strings that need to be accessible to applications performing text-based
206
- indexing and search operations, but which should not be displayed to end users.
207
- </summary>
208
- <div>
209
- <p>
210
- Hidden labels may for instance be used to include misspelled variants of other
211
- lexical labels.
212
- </p>
213
- <p>
214
- <WarningOutlineIcon /> Preferred, alternative, and hidden label sets must not
215
- overlap.
216
- </p>
217
- </div>
218
- </details>
219
- ),
98
+ description:
99
+ 'Character strings that need to be accessible to applications performing text-based indexing and search operations, but which should not be displayed to end users.',
220
100
  of: [{type: 'string'}],
221
101
  validation: (Rule) => Rule.unique(),
222
102
  }),
@@ -237,17 +117,8 @@ export default function skosConcept(
237
117
  defineField({
238
118
  name: 'broader',
239
119
  title: 'Broader Concept(s)',
240
- description: (
241
- <details className={styles.description}>
242
- <summary>
243
- Create hierarchy between concepts, for example to create category/subcategory,
244
- part/whole, or class/instance relationships.
245
- </summary>
246
- <div>
247
- <p>Broader and Associated relationships are mutually exclusive.</p>
248
- </div>
249
- </details>
250
- ),
120
+ description:
121
+ 'Create hierarchy between concepts, for example to create category/subcategory, part/whole, or class/instance relationships.',
251
122
  type: 'array',
252
123
  of: [
253
124
  {
@@ -262,17 +133,8 @@ export default function skosConcept(
262
133
  defineField({
263
134
  name: 'related',
264
135
  title: 'Related Concept(s)',
265
- description: (
266
- <details className={styles.description}>
267
- <summary>
268
- Indicate that two concepts are inherently "related", but that one is not in any way
269
- more general than the other.
270
- </summary>
271
- <div>
272
- <p>Broader and Associated relationships are mutually exclusive.</p>
273
- </div>
274
- </details>
275
- ),
136
+ description:
137
+ 'Indicate that two concepts are inherently "related", but that one is not in any way more general than the other.',
276
138
  type: 'array',
277
139
  of: [
278
140
  {
@@ -289,96 +151,23 @@ export default function skosConcept(
289
151
  name: 'historyNote',
290
152
  title: 'History Notes',
291
153
  type: 'text',
292
- description: (
293
- <details className={styles.description}>
294
- <summary>Significant changes to the meaning or the form of this concept.</summary>
295
- <div>
296
- <kbd>
297
- Example: person with disabilities
298
- <br />
299
- History Note: "Estab. 1992; heading was: handicapped [1884 - 1992]."
300
- </kbd>
301
- <p>
302
- For more information on the recommended usage of the SKOS documentation properties,
303
- see
304
- <a
305
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
306
- target="_blank"
307
- rel="noreferrer"
308
- >
309
- {' '}
310
- W3C SKOS Primer: 2.4 Documentary Notes
311
- </a>
312
- </p>
313
- </div>
314
- </details>
315
- ),
154
+ description: 'Significant changes to the meaning or the form of this concept.',
316
155
  rows: 3,
317
156
  }),
318
157
  defineField({
319
158
  name: 'editorialNote',
320
159
  title: 'Editorial Notes',
321
160
  type: 'text',
322
- description: (
323
- <details className={styles.description}>
324
- <summary>
325
- Information to aid in administrative housekeeping, such as reminders of editorial work
326
- still to be done, or warnings in the event that future editorial changes might be
327
- made.
328
- </summary>
329
- <div>
330
- <kbd>
331
- Example: doubleclick
332
- <br />
333
- Editorial Note: "Review this term after the company merger is complete."
334
- </kbd>
335
- <p>
336
- For more information on the recommended usage of the SKOS documentation properties,
337
- see
338
- <a
339
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
340
- target="_blank"
341
- rel="noreferrer"
342
- >
343
- {' '}
344
- W3C SKOS Primer: 2.4 Documentary Notes
345
- </a>
346
- </p>
347
- </div>
348
- </details>
349
- ),
161
+ description:
162
+ 'Information to aid in administrative housekeeping, such as reminders of editorial work still to be done, or warnings in the event that future editorial changes might be made.',
350
163
  rows: 3,
351
164
  }),
352
165
  defineField({
353
166
  name: 'changeNote',
354
167
  title: 'Change Notes',
355
168
  type: 'text',
356
- description: (
357
- <details className={styles.description}>
358
- <summary>
359
- Fine-grained changes to a concept, for the purposes of administration and maintenance.
360
- </summary>
361
- <div>
362
- <kbd>
363
- Example: tomato
364
- <br />
365
- Change Note: "Moved from under 'fruits' to under 'vegetables' by Horace Gray"
366
- </kbd>
367
- <p>
368
- For more information on the recommended usage of the SKOS documentation properties,
369
- see
370
- <a
371
- href="https://www.w3.org/TR/2009/NOTE-skos-primer-20090818/#secdocumentation"
372
- target="_blank"
373
- rel="noreferrer"
374
- >
375
- {' '}
376
- W3C SKOS Primer: 2.4 Documentary Notes
377
- </a>
378
- </p>
379
- </div>
380
- </details>
381
- ),
169
+ description:
170
+ 'Fine-grained changes to a concept, for the purposes of administration and maintenance.',
382
171
  rows: 3,
383
172
  }),
384
173
  ],
@@ -1,10 +1,8 @@
1
- // import {nanoid} from 'nanoid'
2
- // import type {FieldDefinition} from 'sanity'
3
1
  import {defineArrayMember, defineField, defineType} from 'sanity'
4
2
 
5
3
  import {Identifier, ManagementControls} from './components/inputs'
6
4
  import baseIriField from './helpers/baseIriField'
7
- import {createId} from './helpers/createId'
5
+ import {createId} from './core/createId'
8
6
  import NodeTree from './static/NodeTree'
9
7
  import type {Options} from './types'
10
8
 
@@ -14,7 +12,7 @@ import type {Options} from './types'
14
12
  export default function skosConceptScheme(
15
13
  baseUri?: Options['baseUri'],
16
14
  customSchemeFields: Options['customSchemeFields'] = [],
17
- ident?: Options['ident']
15
+ ident?: Options['ident'],
18
16
  ) {
19
17
  return defineType({
20
18
  name: 'skosConceptScheme',
@@ -0,0 +1,60 @@
1
+ import {EditIcon, DocumentsIcon} from '@sanity/icons'
2
+ import type {StructureBuilder, ListBuilder, DefaultDocumentNodeResolver} from 'sanity/structure'
3
+
4
+ import {TreeView} from './components/TreeView'
5
+ import {TaxonomyConfigContext} from './context'
6
+ import NodeTree from './static/NodeTree'
7
+ import type {Options, TreeViewProps} from './types'
8
+ import {ConceptUseView} from './views/ConceptUseView'
9
+
10
+ /**
11
+ * #### Default Desk Structure for Concept and Concept Scheme
12
+ * Sets defaultDocumentNode. Consider exporting in the future,
13
+ * if there is a use case for mixing taxonomy views in the main
14
+ * desk structure.
15
+ */
16
+ export const structure = (S: StructureBuilder): ListBuilder =>
17
+ S.list()
18
+ .title('Taxonomy Manager')
19
+ .items([
20
+ S.documentTypeListItem('skosConceptScheme').title('Concept Schemes'),
21
+ S.documentTypeListItem('skosConcept').title('Concepts'),
22
+ ])
23
+
24
+ /**
25
+ * #### Default Document Node (factory)
26
+ * Builds the `defaultDocumentNode` resolver, closing over plugin config so the
27
+ * structure Tree View can read `ident` from React context — replacing the old
28
+ * `config.ts` `getPluginConfig` singleton. Called once at plugin init, so the
29
+ * wrapper component identity stays stable across resolver invocations.
30
+ */
31
+ export const createDefaultDocumentNode = (
32
+ config: {ident?: Options['ident']} = {}
33
+ ): DefaultDocumentNodeResolver => {
34
+ // Provide plugin config to the structure Tree View subtree (the only place
35
+ // concept creation reads `ident`). Input-component trees render without this
36
+ // provider and fall back to the context default — they never create concepts.
37
+ const TreeViewWithConfig = (props: TreeViewProps) => (
38
+ <TaxonomyConfigContext.Provider value={{ident: config.ident}}>
39
+ <TreeView {...props} />
40
+ </TaxonomyConfigContext.Provider>
41
+ )
42
+
43
+ return (S, {schemaType}) => {
44
+ // Conditionally return a different configuration based on the schema type
45
+ switch (schemaType) {
46
+ case 'skosConceptScheme':
47
+ return S.document().views([
48
+ S.view.component(TreeViewWithConfig).title('Tree View').icon(NodeTree),
49
+ S.view.form().icon(EditIcon),
50
+ ])
51
+ case 'skosConcept':
52
+ return S.document().views([
53
+ S.view.form().icon(EditIcon),
54
+ S.view.component(ConceptUseView).title('Tagged Resources').icon(DocumentsIcon),
55
+ ])
56
+ default:
57
+ return S.document().views([S.view.form().icon(EditIcon)])
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,62 @@
1
+ import {renderHook} from '@testing-library/react'
2
+ import {describe, expect, it} from 'vitest'
3
+
4
+ import {planCreateConcept} from '../core/mutations'
5
+ import type {ConceptSchemeDocument, DocumentConcepts} from '../types'
6
+
7
+ import {createFakeDataPort} from './FakeDataPort'
8
+
9
+ const scheme = {_id: 'scheme-1', _type: 'skosConceptScheme'} as ConceptSchemeDocument['displayed']
10
+ const TREE: DocumentConcepts = {topConcepts: [], concepts: []}
11
+
12
+ describe('createFakeDataPort', () => {
13
+ it('useWatchTree returns the configured fixture synchronously and records params', () => {
14
+ const port = createFakeDataPort({tree: TREE})
15
+ const params = {mode: 'trunk', documentId: 'scheme-1', perspective: undefined} as const
16
+
17
+ const {result} = renderHook(() => port.useWatchTree(params))
18
+
19
+ expect(result.current).toEqual({data: TREE, loading: false, error: null})
20
+ expect(port.lastWatchParams).toEqual(params)
21
+ })
22
+
23
+ it('defaults to a null, non-loading, error-free result', () => {
24
+ const port = createFakeDataPort()
25
+ const {result} = renderHook(() =>
26
+ port.useWatchTree({mode: 'trunk', documentId: 's', perspective: undefined})
27
+ )
28
+ expect(result.current).toEqual({data: null, loading: false, error: null})
29
+ })
30
+
31
+ it('useApplyConceptPlan records each plan it is given, in order', async () => {
32
+ const port = createFakeDataPort()
33
+ const plan = planCreateConcept({
34
+ scheme,
35
+ conceptType: 'topConcept',
36
+ newConceptUuid: 'u',
37
+ conceptId: 'abc123',
38
+ schemeBaseIri: undefined,
39
+ newConceptKey: 'k',
40
+ broaderKey: 'b',
41
+ })
42
+
43
+ const {result} = renderHook(() => port.useApplyConceptPlan())
44
+ await result.current(plan)
45
+
46
+ expect(port.appliedPlans).toEqual([plan])
47
+ })
48
+
49
+ it('useSemanticRecommendations returns configured recs and counts search triggers', () => {
50
+ const port = createFakeDataPort({
51
+ recommendations: [{conceptId: 'd1', score: 0.9}],
52
+ })
53
+
54
+ const {result} = renderHook(() => port.useSemanticRecommendations())
55
+ expect(result.current.conceptRecs).toHaveLength(1)
56
+ expect(result.current.recsError).toBeNull()
57
+
58
+ result.current.triggerSearch('scheme-7')
59
+ expect(port.triggeredSearches).toBe(1)
60
+ expect(port.lastSearchSchemeId).toBe('scheme-7')
61
+ })
62
+ })
@@ -0,0 +1,69 @@
1
+ import type {
2
+ ConceptPlan,
3
+ ConceptTreeParams,
4
+ SemanticRecommendationsResult,
5
+ TaxonomyDataPort,
6
+ WatchResult,
7
+ } from '../core/ports'
8
+ import type {ConceptRecommendation, DocumentConcepts} from '../types'
9
+
10
+ /** Fixtures + canned responses for a {@link createFakeDataPort}. */
11
+ export interface FakeDataPortConfig {
12
+ tree?: DocumentConcepts | null
13
+ loading?: boolean
14
+ error?: Error | null
15
+ recommendations?: ConceptRecommendation[]
16
+ recsError?: string | null
17
+ }
18
+
19
+ /** A {@link TaxonomyDataPort} that also exposes recorded interactions for assertions. */
20
+ export interface FakeDataPort extends TaxonomyDataPort {
21
+ /** Plans passed to the applyConceptPlan function, in call order. */
22
+ readonly appliedPlans: ConceptPlan[]
23
+ /** Number of `triggerSearch()` calls. */
24
+ triggeredSearches: number
25
+ /** The `schemeId` passed to the most recent `triggerSearch()` call. */
26
+ lastSearchSchemeId: string | undefined
27
+ /** Params from the most recent `useWatchTree` call. */
28
+ lastWatchParams: ConceptTreeParams | null
29
+ }
30
+
31
+ /**
32
+ * In-memory `TaxonomyDataPort` for component/tree tests. Returns the configured
33
+ * fixtures synchronously (no Studio, no network) and records mutations and
34
+ * search triggers for assertions. The hook members call no React hooks, so they
35
+ * return stable references and never suspend. Provide via:
36
+ * `<TaxonomyPortProvider port={createFakeDataPort({tree})}>`.
37
+ */
38
+ export function createFakeDataPort(config: FakeDataPortConfig = {}): FakeDataPort {
39
+ const watchResult: WatchResult<DocumentConcepts> = {
40
+ data: config.tree ?? null,
41
+ loading: config.loading ?? false,
42
+ error: config.error ?? null,
43
+ }
44
+ const recsResult: SemanticRecommendationsResult = {
45
+ conceptRecs: config.recommendations ?? [],
46
+ recsError: config.recsError ?? null,
47
+ triggerSearch: (schemeId?: string) => {
48
+ fake.triggeredSearches += 1
49
+ fake.lastSearchSchemeId = schemeId
50
+ },
51
+ }
52
+ const apply = (plan: ConceptPlan): Promise<void> => {
53
+ fake.appliedPlans.push(plan)
54
+ return Promise.resolve()
55
+ }
56
+ const fake: FakeDataPort = {
57
+ appliedPlans: [],
58
+ triggeredSearches: 0,
59
+ lastSearchSchemeId: undefined,
60
+ lastWatchParams: null,
61
+ useWatchTree: (params: ConceptTreeParams) => {
62
+ fake.lastWatchParams = params
63
+ return watchResult
64
+ },
65
+ useApplyConceptPlan: () => apply,
66
+ useSemanticRecommendations: () => recsResult,
67
+ }
68
+ return fake
69
+ }
@@ -0,0 +1,71 @@
1
+ import {vi} from 'vitest'
2
+
3
+ import type {ArrayHierarchyInput} from '../components/inputs/ArrayHierarchyInput'
4
+ import type {ReferenceHierarchyInput} from '../components/inputs/ReferenceHierarchyInput'
5
+
6
+ /**
7
+ * Shared fixtures for the input-component jsdom tests. The components read Studio
8
+ * primitives (`useClient`/`useFormValue`/`usePerspective`, `FormField`) that each
9
+ * test file stubs via `vi.mock('sanity', …)`; this module supplies the bulky,
10
+ * mock-independent pieces: a chainable client stub and field-props factories.
11
+ */
12
+
13
+ /** A Studio client stub that resolves the scheme lookup and no-ops mutations. */
14
+ export function fakeSchemeClient(
15
+ scheme: Record<string, unknown> = {_id: 'scheme-x', _type: 'skosConceptScheme', title: 'Test Scheme'}
16
+ ) {
17
+ const client: Record<string, unknown> = {
18
+ withConfig: () => client,
19
+ fetch: vi.fn().mockResolvedValue({displayed: scheme}),
20
+ patch: vi.fn(() => client),
21
+ set: vi.fn(() => client),
22
+ setIfMissing: vi.fn(() => client),
23
+ append: vi.fn(() => client),
24
+ commit: vi.fn().mockResolvedValue(undefined),
25
+ create: vi.fn().mockResolvedValue(undefined),
26
+ transaction: vi.fn(() => client),
27
+ }
28
+ return client as unknown as ReturnType<typeof import('sanity').useClient>
29
+ }
30
+
31
+ /**
32
+ * A resolved field filter. Real `schemeFilter()`/`branchFilter()` resolve to a
33
+ * single-argument function; the components treat a zero-arity filter as
34
+ * misconfigured (`filter.length === 0`), so this keeps arity 1.
35
+ */
36
+ export const filterFor =
37
+ (params: Record<string, unknown>, extra: Record<string, unknown> = {}) =>
38
+ (_opts: unknown) =>
39
+ Promise.resolve({filter: 'scheme-filter', params, ...extra})
40
+
41
+ const sharedProps = {
42
+ level: 0,
43
+ value: undefined,
44
+ validation: [],
45
+ presence: [],
46
+ renderDefault: () => <div data-testid="default-input">default input</div>,
47
+ }
48
+
49
+ /** Minimal `ReferenceHierarchyInput` props; `renderDefault` renders a recognizable marker. */
50
+ export function referenceFieldProps(overrides: Record<string, unknown> = {}) {
51
+ return {
52
+ ...sharedProps,
53
+ name: 'subject',
54
+ title: 'Subject',
55
+ path: ['subject'],
56
+ schemaType: {options: {filter: filterFor({schemeId: 'scheme-x'})}},
57
+ ...overrides,
58
+ } as unknown as Parameters<typeof ReferenceHierarchyInput>[0]
59
+ }
60
+
61
+ /** Minimal `ArrayHierarchyInput` props; the filter lives on `of[0].options`. */
62
+ export function arrayFieldProps(overrides: Record<string, unknown> = {}) {
63
+ return {
64
+ ...sharedProps,
65
+ name: 'topics',
66
+ title: 'Topics',
67
+ path: ['topics'],
68
+ schemaType: {of: [{options: {filter: filterFor({schemeId: 'scheme-x'})}}]},
69
+ ...overrides,
70
+ } as unknown as Parameters<typeof ArrayHierarchyInput>[0]
71
+ }
@@ -0,0 +1,14 @@
1
+ import {Text} from '@sanity/ui'
2
+ import {screen} from '@testing-library/react'
3
+ import {describe, expect, it} from 'vitest'
4
+
5
+ import {renderWithUi} from './renderWithUi'
6
+
7
+ // Smoke test for the whole harness: vitest + jsdom + esbuild JSX +
8
+ // test-setup (matchMedia/jest-dom) + the @sanity/ui provider stack.
9
+ describe('test harness', () => {
10
+ it('renders a @sanity/ui component inside the provider stack', () => {
11
+ renderWithUi(<Text>taxonomy harness ok</Text>)
12
+ expect(screen.getByText('taxonomy harness ok')).toBeInTheDocument()
13
+ })
14
+ })
@@ -0,0 +1,24 @@
1
+ import {LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui'
2
+ import {buildTheme} from '@sanity/ui/theme'
3
+ import {render, type RenderResult} from '@testing-library/react'
4
+ import type {ReactElement, ReactNode} from 'react'
5
+
6
+ const theme = buildTheme()
7
+
8
+ /**
9
+ * Render a component inside the @sanity/ui provider stack (theme, layers,
10
+ * toasts) so Studio UI primitives — popovers, portals, toasts — behave as they
11
+ * do in the Studio, without booting a full Studio. Used by component
12
+ * interaction tests.
13
+ */
14
+ export function renderWithUi(ui: ReactElement): RenderResult {
15
+ return render(ui, {
16
+ wrapper: ({children}: {children: ReactNode}) => (
17
+ <ThemeProvider theme={theme}>
18
+ <LayerProvider>
19
+ <ToastProvider>{children}</ToastProvider>
20
+ </LayerProvider>
21
+ </ThemeProvider>
22
+ ),
23
+ })
24
+ }