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,22 +1,13 @@
1
1
  import type {useClient} from 'sanity'
2
2
 
3
+ import {assertSchemeId, buildSchemeFilterResult, type SchemeFilterResult} from '../core/filters'
4
+
3
5
  type SchemeOptions = {
4
6
  schemeId: string
5
7
  expanded?: boolean
6
8
  browseOnly?: boolean
7
9
  }
8
10
 
9
- type SchemeFilterResult = {
10
- filter: string
11
- params: {
12
- schemeId: string
13
- concepts: string[]
14
- topConcepts: string[]
15
- }
16
- expanded?: boolean
17
- browseOnly?: boolean
18
- }
19
-
20
11
  /**
21
12
  * #### Reference Field Scheme Filter
22
13
  * Pluggable Function for Filtering to a Single SKOS Concept Scheme.
@@ -53,7 +44,7 @@ type SchemeFilterResult = {
53
44
  * ```
54
45
  */
55
46
  export const schemeFilter = (
56
- options: SchemeOptions
47
+ options: SchemeOptions,
57
48
  ): (({
58
49
  getClient,
59
50
  }: {
@@ -61,10 +52,7 @@ export const schemeFilter = (
61
52
  }) => Promise<SchemeFilterResult>) => {
62
53
  // Get and validate the schemeId from options
63
54
  const {schemeId} = options || {}
64
-
65
- if (!schemeId || typeof schemeId !== 'string') {
66
- throw new Error('Invalid or missing schemeId: scheme Id must be a string')
67
- }
55
+ assertSchemeId(schemeId)
68
56
 
69
57
  return async (props) => {
70
58
  const client = props?.getClient({apiVersion: '2025-02-19'})
@@ -72,20 +60,20 @@ export const schemeFilter = (
72
60
  throw new Error('Client not available')
73
61
  }
74
62
  // Fetch concepts and topConcepts for the given schemeId
75
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unnecessary-type-assertion
63
+
76
64
  const {concepts, topConcepts} = (await client.fetch(
77
65
  `{
78
66
  "concepts": *[_type=="skosConceptScheme" && schemeId == "${schemeId}"].concepts[]._ref,
79
67
  "topConcepts": *[_type=="skosConceptScheme" && schemeId == "${schemeId}"].topConcepts[]._ref
80
- }`
68
+ }`,
81
69
  )) as {concepts: string[]; topConcepts: string[]}
82
70
  // schemeId is included in params for use by the ArrayHierarchyInput component
83
- return {
84
- filter: `(_id in $concepts
85
- || _id in $topConcepts)`,
86
- params: {concepts, topConcepts, schemeId},
71
+ return buildSchemeFilterResult({
72
+ schemeId,
73
+ concepts,
74
+ topConcepts,
87
75
  expanded: options?.expanded,
88
76
  browseOnly: options?.browseOnly,
89
- }
77
+ })
90
78
  }
91
79
  }
@@ -2,4 +2,4 @@ export * from './useAddTitle'
2
2
  export * from './useCreateConcept'
3
3
  export * from './useOpenNewConceptPane'
4
4
  export * from './useRemoveConcept'
5
- export * from './useEmbeddingsRecs'
5
+ export * from './useSemanticRecommendations'
@@ -1,129 +1,52 @@
1
- import {
2
- DocumentId,
3
- getDraftId,
4
- getVersionId,
5
- getVersionNameFromId,
6
- isVersionId,
7
- getPublishedId,
8
- type VersionId,
9
- } from '@sanity/id-utils'
10
1
  import {useToast} from '@sanity/ui'
11
2
  import {uuid} from '@sanity/uuid'
12
- import {useCallback} from 'react'
13
- import {isPublishedId, useClient} from 'sanity'
3
+ import {useCallback, useContext} from 'react'
14
4
 
15
- import {getPluginConfig} from '../config'
16
- import {createId} from '../helpers/createId'
17
- import type {SkosConceptDocument, SkosConceptReference, ConceptSchemeDocument} from '../types'
5
+ import {TaxonomyConfigContext} from '../context'
6
+ import {createId} from '../core/createId'
7
+ import {planCreateConcept} from '../core/mutations'
8
+ import {useTaxonomyDataPort} from '../seams/TaxonomyPortContext'
9
+ import type {ConceptSchemeDocument} from '../types'
18
10
 
19
11
  import {useOpenNewConceptPane} from './useOpenNewConceptPane'
20
12
 
21
13
  /**
22
14
  * #### Concept Creation Hook
23
- * Used for creating concepts and top concepts from the
24
- * Concept Scheme hierarchy view.
15
+ * Used for creating concepts and top concepts from the Concept Scheme hierarchy
16
+ * view. The release/version-aware transaction is planned purely in
17
+ * `core/mutations` and executed through the data port; this hook stays thin —
18
+ * it generates the random ids/keys, builds the plan, and handles the toast +
19
+ * new-pane navigation.
25
20
  */
26
21
  export function useCreateConcept(document: ConceptSchemeDocument) {
27
22
  const toast = useToast()
28
- const client = useClient({apiVersion: '2025-02-19'})
29
23
  const openInNewPane = useOpenNewConceptPane()
24
+ const port = useTaxonomyDataPort()
25
+ const applyConceptPlan = port.useApplyConceptPlan()
30
26
 
31
- // Get ident config from plugin
32
- const pluginConfig = getPluginConfig()
33
- const ident = pluginConfig?.ident
34
-
27
+ // Ident config (identifier generation) comes from plugin options via context.
28
+ const {ident} = useContext(TaxonomyConfigContext)
35
29
  const schemaBaseIri = document.displayed.baseIri
36
30
 
37
31
  const createConcept = useCallback(
38
- (
39
- conceptType: 'topConcept' | 'concept',
40
- concept?: {
41
- id: string
42
- _originalId?: string
43
- }
44
- ) => {
45
- // destructure IDs and rename for this context
46
- const {id: broaderConceptId, _originalId: broaderConceptOriginalId = ''} = concept || {}
47
- // check if the skosConceptScheme is in a release
48
- const isInRelease = isVersionId(document.displayed._id as DocumentId)
49
- // if so, get the release name
50
- const releaseName = isInRelease
51
- ? getVersionNameFromId(document.displayed._id as VersionId)
52
- : undefined
53
-
54
- // create a scheme ID based on context
55
- const schemeId = isInRelease
56
- ? getVersionId(DocumentId(document.displayed._id), releaseName as string)
57
- : getDraftId(DocumentId(document.displayed._id))
58
-
59
- // Generate the appropriate concept ID based on context
60
- const newConceptId = isInRelease
61
- ? getVersionId(DocumentId(uuid()), releaseName as string)
62
- : getDraftId(DocumentId(uuid()))
63
-
64
- // create the new skosConcept document
65
- const skosConcept: SkosConceptDocument = {
66
- _id: newConceptId, // either a draft ID or a release ID
67
- _type: 'skosConcept',
32
+ (conceptType: 'topConcept' | 'concept', concept?: {id: string; _originalId?: string}) => {
33
+ const plan = planCreateConcept({
34
+ scheme: document.displayed,
35
+ conceptType,
36
+ broaderConcept: concept?.id
37
+ ? {id: concept.id, _originalId: concept._originalId ?? ''}
38
+ : undefined,
39
+ newConceptUuid: uuid(),
68
40
  conceptId: createId(ident),
69
- prefLabel: '',
70
- baseIri: schemaBaseIri,
71
- broader: [],
72
- related: [],
73
- }
74
-
75
- // if a broader concept ID is provided, add it to the skosConcept
76
- if (broaderConceptId) {
77
- // check if the broader concept is published
78
- const isPublished = isPublishedId(DocumentId(broaderConceptOriginalId))
79
- // add broader as _strengthenOnPublish if it's not published
80
- skosConcept.broader = [
81
- {
82
- _key: uuid(),
83
- _ref: getPublishedId(DocumentId(broaderConceptId)),
84
- _type: 'reference',
85
- _weak: !isPublished,
86
- _strengthenOnPublish: isPublished
87
- ? undefined
88
- : {
89
- type: 'skosConcept',
90
- template: {id: 'skosConcept'},
91
- },
92
- },
93
- ]
94
- }
95
-
96
- const skosConceptReference: SkosConceptReference = {
97
- _ref: getPublishedId(newConceptId),
98
- _type: 'reference',
99
- _key: uuid(),
100
- _strengthenOnPublish: {
101
- type: 'skosConcept',
102
- template: {id: 'skosConcept'},
103
- },
104
- _weak: true,
105
- }
106
-
107
- client
108
- .transaction()
109
- .createIfNotExists({...document.displayed, _id: schemeId})
110
- .create(skosConcept)
111
- .patch(schemeId, (patch) => {
112
- if (conceptType === 'topConcept') {
113
- return patch
114
- .setIfMissing({topConcepts: []})
115
- .append('topConcepts', [skosConceptReference])
116
- }
117
- return patch.setIfMissing({concepts: []}).append('concepts', [skosConceptReference])
118
- })
119
- .commit({autoGenerateArrayKeys: true})
120
- .then((_res) => {
121
- toast.push({
122
- closable: true,
123
- status: 'success',
124
- title: 'Created new concept',
125
- })
126
- openInNewPane(newConceptId)
41
+ schemeBaseIri: schemaBaseIri,
42
+ newConceptKey: uuid(),
43
+ broaderKey: uuid(),
44
+ })
45
+
46
+ applyConceptPlan(plan)
47
+ .then(() => {
48
+ toast.push({closable: true, status: 'success', title: 'Created new concept'})
49
+ openInNewPane(plan.newConceptId)
127
50
  })
128
51
  .catch((err) => {
129
52
  toast.push({
@@ -134,7 +57,7 @@ export function useCreateConcept(document: ConceptSchemeDocument) {
134
57
  })
135
58
  })
136
59
  },
137
- [document.displayed, ident, schemaBaseIri, client, toast, openInNewPane]
60
+ [document.displayed, ident, schemaBaseIri, applyConceptPlan, toast, openInNewPane]
138
61
  )
139
62
  return createConcept
140
63
  }
@@ -1,53 +1,39 @@
1
- import {
2
- DocumentId,
3
- getDraftId,
4
- getVersionId,
5
- getVersionNameFromId,
6
- isVersionId,
7
- type VersionId,
8
- } from '@sanity/id-utils'
9
1
  import {useToast} from '@sanity/ui'
10
- import {useCallback} from 'react'
11
- import {useClient} from 'sanity'
2
+ import {useCallback, useContext} from 'react'
12
3
 
4
+ import {OptimisticTreeContext} from '../context'
5
+ import {planRemoveConcept} from '../core/mutations'
6
+ import {useTaxonomyDataPort} from '../seams/TaxonomyPortContext'
13
7
  import type {ConceptSchemeDocument} from '../types'
14
8
 
15
9
  /**
16
10
  * #### Concept Removal Hook
17
- * Used for removing concepts and top concepts from
18
- * the Concept Scheme hierarchy view.
11
+ * Used for removing concepts and top concepts from the Concept Scheme hierarchy
12
+ * view. The release/version-aware transaction is planned purely in
13
+ * `core/mutations` and executed through the data port; this hook replays the
14
+ * plan, prunes the concept from the tree optimistically (reverting if the
15
+ * mutation fails), and handles the toast.
19
16
  */
20
17
  export function useRemoveConcept(document: ConceptSchemeDocument) {
21
18
  const toast = useToast()
22
- const client = useClient({apiVersion: '2025-02-19'})
19
+ const port = useTaxonomyDataPort()
20
+ const applyConceptPlan = port.useApplyConceptPlan()
21
+ const {markRemoved, unmarkRemoved} = useContext(OptimisticTreeContext)
23
22
 
24
23
  // conceptId is the id of the concept to be removed
25
24
  const removeConcept = useCallback(
26
25
  (conceptId: string, conceptType: string, prefLabel?: string) => {
27
- const type = conceptType == 'topConcept' ? 'topConcepts' : 'concepts'
26
+ const plan = planRemoveConcept({
27
+ scheme: document.displayed,
28
+ conceptRef: conceptId,
29
+ conceptType,
30
+ })
28
31
 
29
- const isInRelease = isVersionId(document.displayed._id as DocumentId)
30
- const releaseName = isInRelease
31
- ? getVersionNameFromId(document.displayed._id as VersionId)
32
- : undefined
32
+ // Prune from the tree immediately; the live listener reconciles after.
33
+ markRemoved(conceptId)
33
34
 
34
- const schemeId = isInRelease
35
- ? getVersionId(DocumentId(document.displayed._id), releaseName as string)
36
- : getDraftId(DocumentId(document.displayed._id))
37
-
38
- // Ensure concepts are removed from a draft of the concept scheme document
39
- // const draftConceptScheme = JSON.parse(JSON.stringify(document.displayed))
40
-
41
- // if (!draftConceptScheme._id.includes('drafts.')) {
42
- // draftConceptScheme._id = `drafts.${draftConceptScheme._id}`
43
- // }
44
-
45
- client
46
- .transaction()
47
- .createIfNotExists({...document.displayed, _id: schemeId})
48
- .patch(schemeId, (patch) => patch.unset([`${type}[_ref=="${conceptId}"]`]))
49
- .commit()
50
- .then((_res) => {
35
+ applyConceptPlan(plan)
36
+ .then(() => {
51
37
  toast.push({
52
38
  closable: true,
53
39
  status: 'success',
@@ -55,6 +41,8 @@ export function useRemoveConcept(document: ConceptSchemeDocument) {
55
41
  })
56
42
  })
57
43
  .catch((err) => {
44
+ // Roll the optimistic removal back so the concept reappears.
45
+ unmarkRemoved(conceptId)
58
46
  toast.push({
59
47
  closable: true,
60
48
  status: 'error',
@@ -63,7 +51,7 @@ export function useRemoveConcept(document: ConceptSchemeDocument) {
63
51
  })
64
52
  })
65
53
  },
66
- [client, document.displayed, toast]
54
+ [applyConceptPlan, document.displayed, toast, markRemoved, unmarkRemoved]
67
55
  )
68
56
  return removeConcept
69
57
  }
@@ -0,0 +1,75 @@
1
+ import {useState, useCallback, useMemo} from 'react'
2
+ import {useClient, useGetFormValue} from 'sanity'
3
+
4
+ import {
5
+ assembleQueryText,
6
+ recommendationsErrorMessage,
7
+ recommendationsQuery,
8
+ toConceptRecommendations,
9
+ type RecommendationRow,
10
+ } from '../core/semanticRecommendations'
11
+ import type {ConceptRecommendation, SemanticSearchConfig} from '../types'
12
+
13
+ /**
14
+ * GROQ API version for the recommendations query. `text::semanticSimilarity()`
15
+ * is documented as a stable function, but its minimum dated API version isn't
16
+ * published; `vX` is the channel known to expose it (and what the pre-migration
17
+ * embeddings hook already used, so this is no stability regression). Pin to a
18
+ * dated version once confirmed against the live API.
19
+ */
20
+ const SEMANTIC_API_VERSION = 'vX'
21
+
22
+ /** Default number of recommended terms — preserved from the prior behavior. */
23
+ const DEFAULT_MAX_RESULTS = 3
24
+
25
+ /**
26
+ * Studio-side implementation of the semantic-recommendations seam. Reads the
27
+ * configured form fields, runs the `text::semanticSimilarity()` query against the
28
+ * published perspective, and exposes the scored concepts plus a friendly error.
29
+ * All decision-bearing logic (query, validation, mapping, error wording) lives in
30
+ * the pure `core/semanticRecommendations`; this hook only wires Studio (client +
31
+ * form values) and React state.
32
+ */
33
+ export function useSemanticRecommendations(semanticSearch?: SemanticSearchConfig) {
34
+ // Recommend established terms: score against the published perspective, matching
35
+ // the old published embeddings index. Memoized so the client ref stays stable.
36
+ const baseClient = useClient({apiVersion: SEMANTIC_API_VERSION})
37
+ const client = useMemo(() => baseClient.withConfig({perspective: 'published'}), [baseClient])
38
+ const getFormValue = useGetFormValue()
39
+
40
+ const [conceptRecs, setConceptRecs] = useState<ConceptRecommendation[]>([])
41
+ const [recsError, setRecsError] = useState<string | null>(null)
42
+
43
+ const triggerSearch = useCallback(
44
+ (schemeId?: string) => {
45
+ setRecsError(null)
46
+ // Recommendations are scoped to the field's scheme; without a resolved
47
+ // schemeId there is nothing to scope against, so skip the search.
48
+ if (!semanticSearch || !schemeId) return
49
+ const {fieldReferences, maxResults = DEFAULT_MAX_RESULTS} = semanticSearch
50
+
51
+ let searchQuery: string
52
+ try {
53
+ searchQuery = assembleQueryText(
54
+ fieldReferences.map((name) => ({name, value: getFormValue([name])}))
55
+ )
56
+ } catch (error) {
57
+ setRecsError(
58
+ error instanceof Error ? error.message : 'One or more required fields are empty'
59
+ )
60
+ return
61
+ }
62
+
63
+ client
64
+ .fetch(recommendationsQuery(), {searchQuery, maxResults, schemeId})
65
+ .then((rows: RecommendationRow[]) => setConceptRecs(toConceptRecommendations(rows)))
66
+ .catch((error) => {
67
+ console.error('Error fetching semantic recommendations: ', error)
68
+ setRecsError(recommendationsErrorMessage(error))
69
+ })
70
+ },
71
+ [client, getFormValue, semanticSearch]
72
+ )
73
+
74
+ return {conceptRecs, recsError, triggerSearch}
75
+ }
@@ -0,0 +1,31 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import * as publicApi from './index'
4
+
5
+ // The consumer contract: exactly these names are exported, and the plugin
6
+ // registers exactly these two schema types. Renaming or removing any is a
7
+ // breaking change — pin them so it can't happen by accident before 5.0.0.
8
+
9
+ describe('public export surface', () => {
10
+ it('exports exactly the six documented members', () => {
11
+ expect(Object.keys(publicApi).sort()).toEqual(
12
+ [
13
+ 'ArrayHierarchyInput',
14
+ 'ReferenceHierarchyInput',
15
+ 'TreeView',
16
+ 'branchFilter',
17
+ 'schemeFilter',
18
+ 'taxonomyManager',
19
+ ].sort()
20
+ )
21
+ })
22
+
23
+ it('registers the plugin under its documented name with both schema types', () => {
24
+ const plugin = publicApi.taxonomyManager({})
25
+ expect(plugin.name).toBe('taxonomyManager')
26
+
27
+ const types = plugin.schema?.types
28
+ const typeNames = Array.isArray(types) ? types.map((type) => type.name).sort() : []
29
+ expect(typeNames).toEqual(['skosConcept', 'skosConceptScheme'])
30
+ })
31
+ })
package/src/index.ts CHANGED
@@ -3,12 +3,11 @@ import {structureTool} from 'sanity/structure'
3
3
 
4
4
  import {ReferenceHierarchyInput, ArrayHierarchyInput} from './components/inputs'
5
5
  import {TreeView} from './components/TreeView'
6
- import {setPluginConfig} from './config'
7
6
  import {schemeFilter, branchFilter} from './helpers'
8
7
  import skosConcept from './skosConcept'
9
8
  import skosConceptScheme from './skosConceptScheme'
10
9
  import NodeTree from './static/NodeTree'
11
- import {defaultDocumentNode, structure} from './structure'
10
+ import {createDefaultDocumentNode, structure} from './structure'
12
11
  import type {Options} from './types'
13
12
 
14
13
  /**
@@ -28,9 +27,6 @@ import type {Options} from './types'
28
27
  const taxonomyManager = definePlugin((options?: Options) => {
29
28
  const {baseUri, customConceptFields, customSchemeFields, ident} = options || {}
30
29
 
31
- // Store config for access in hooks
32
- setPluginConfig(options)
33
-
34
30
  return {
35
31
  name: 'taxonomyManager',
36
32
  options,
@@ -45,7 +41,7 @@ const taxonomyManager = definePlugin((options?: Options) => {
45
41
  name: 'taxonomy',
46
42
  title: 'Taxonomy',
47
43
  structure,
48
- defaultDocumentNode,
44
+ defaultDocumentNode: createDefaultDocumentNode({ident}),
49
45
  icon: NodeTree,
50
46
  }),
51
47
  ],
@@ -0,0 +1,81 @@
1
+ import {useCallback} from 'react'
2
+ import {useClient} from 'sanity'
3
+ import {useListeningQuery} from 'sanity-plugin-utils'
4
+
5
+ import type {ConceptPlan, ConceptTreeParams, TaxonomyDataPort, WatchResult} from '../core/ports'
6
+ import {inputBuilder, trunkBuilder} from '../core/queries'
7
+ import {useSemanticRecommendations} from '../hooks/useSemanticRecommendations'
8
+ import type {DocumentConcepts} from '../types'
9
+
10
+ /**
11
+ * #### Studio Data Adapter
12
+ * The default `TaxonomyDataPort` implementation: today's behavior, lifted
13
+ * verbatim behind the interface. Tree watching stays on Studio's
14
+ * `documentStore.listenQuery` (via `useListeningQuery`), mutations replay
15
+ * `core/mutations` plans onto `client.transaction()`, and recommendations run a
16
+ * `text::semanticSimilarity()` GROQ query (`useSemanticRecommendations`). Watch
17
+ * and mutation behavior matches the pre-port components; recommendations were
18
+ * migrated off the deprecated Embeddings Index API in Stage 5.
19
+ */
20
+
21
+ // Content Releases API version — must be preserved on the mutation path.
22
+ const MUTATION_API_VERSION = '2025-02-19'
23
+
24
+ function useWatchTree(params: ConceptTreeParams): WatchResult<DocumentConcepts> {
25
+ const isInput = params.mode === 'input'
26
+ const result = useListeningQuery<DocumentConcepts>(
27
+ {
28
+ fetch: isInput ? inputBuilder() : trunkBuilder(),
29
+ listen: isInput
30
+ ? `*[_type == "skosConcept" || _id == $id]`
31
+ : `*[_type == "skosConcept" || _type == "skosConceptScheme" ]`,
32
+ },
33
+ {
34
+ params: isInput
35
+ ? // GROQ's select($branchId != null => …) needs null, not ''. ListenQueryParams
36
+ // rejects null, so we cast past it — preserved from the original InputHierarchy.
37
+ ({id: params.documentId, branchId: params.branchId} as any)
38
+ : {id: params.documentId},
39
+ options: {
40
+ perspective: params.perspective === undefined ? 'drafts' : [params.perspective],
41
+ },
42
+ }
43
+ ) as {data: DocumentConcepts | null; loading: boolean; error: unknown}
44
+
45
+ return {
46
+ data: result.data ?? null,
47
+ loading: result.loading,
48
+ error: result.error ? (result.error as Error) : null,
49
+ }
50
+ }
51
+
52
+ function useApplyConceptPlan(): (plan: ConceptPlan) => Promise<void> {
53
+ const client = useClient({apiVersion: MUTATION_API_VERSION})
54
+ return useCallback(
55
+ async (plan: ConceptPlan): Promise<void> => {
56
+ if (plan.kind === 'remove') {
57
+ await client
58
+ .transaction()
59
+ .createIfNotExists(plan.createIfNotExists)
60
+ .patch(plan.schemeId, (patch) => patch.unset(plan.unsetPaths))
61
+ .commit()
62
+ return
63
+ }
64
+ await client
65
+ .transaction()
66
+ .createIfNotExists(plan.createIfNotExists)
67
+ .create(plan.create)
68
+ .patch(plan.schemeId, (patch) =>
69
+ patch.setIfMissing({[plan.appendField]: []}).append(plan.appendField, [plan.reference])
70
+ )
71
+ .commit({autoGenerateArrayKeys: true})
72
+ },
73
+ [client]
74
+ )
75
+ }
76
+
77
+ export const studioDataAdapter: TaxonomyDataPort = {
78
+ useWatchTree,
79
+ useApplyConceptPlan,
80
+ useSemanticRecommendations,
81
+ }
@@ -0,0 +1,33 @@
1
+ import {createContext, useContext} from 'react'
2
+ import type {ReactElement, ReactNode} from 'react'
3
+
4
+ import type {TaxonomyDataPort} from '../core/ports'
5
+
6
+ import {studioDataAdapter} from './StudioDataAdapter'
7
+
8
+ /**
9
+ * #### Taxonomy Port Context
10
+ * Carries the active `TaxonomyDataPort`. The default is the real
11
+ * `StudioDataAdapter`, so production renders need **no provider mounted** —
12
+ * which matters because Sanity mounts our Tree View (structure) and our field
13
+ * inputs (document forms) in separate trees with no shared root we own.
14
+ *
15
+ * Tests inject an in-memory fake:
16
+ * `<TaxonomyPortProvider port={createFakeDataPort({tree})}>…</TaxonomyPortProvider>`.
17
+ */
18
+ const TaxonomyPortContext = createContext<TaxonomyDataPort>(studioDataAdapter)
19
+
20
+ export function TaxonomyPortProvider({
21
+ port,
22
+ children,
23
+ }: {
24
+ port: TaxonomyDataPort
25
+ children: ReactNode
26
+ }): ReactElement {
27
+ return <TaxonomyPortContext.Provider value={port}>{children}</TaxonomyPortContext.Provider>
28
+ }
29
+
30
+ /** Read the active data port (default: `StudioDataAdapter`). */
31
+ export function useTaxonomyDataPort(): TaxonomyDataPort {
32
+ return useContext(TaxonomyPortContext)
33
+ }