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,4 +1,3 @@
1
- /* eslint-disable react/require-default-props */
2
1
  import type {DocumentId} from '@sanity/id-utils'
3
2
  import {
4
3
  Grid,
@@ -17,9 +16,9 @@ import {useState, useEffect, useCallback} from 'react'
17
16
  import type {ArrayFieldProps, ObjectOptions} from 'sanity'
18
17
  import {FormField, useClient, useFormValue, isVersionId, isDraftId, usePerspective} from 'sanity'
19
18
 
20
- import {useEmbeddingsRecs} from '../../hooks'
19
+ import {useTaxonomyDataPort} from '../../seams/TaxonomyPortContext'
21
20
  import NodeTree from '../../static/NodeTree'
22
- import type {ConceptSchemeDocument, EmbeddingsIndexConfig} from '../../types'
21
+ import type {ConceptSchemeDocument, SemanticSearchConfig} from '../../types'
23
22
  import {TreeView} from '../TreeView'
24
23
 
25
24
  type ReferenceOptions = ObjectOptions & {
@@ -37,7 +36,7 @@ type ReferenceOptions = ObjectOptions & {
37
36
  }
38
37
 
39
38
  type ArrayHierarchyInputProps = ArrayFieldProps & {
40
- embeddingsIndex?: EmbeddingsIndexConfig
39
+ semanticSearch?: SemanticSearchConfig
41
40
  }
42
41
 
43
42
  // Extract the return type of the filter function
@@ -60,19 +59,18 @@ type FilterResult = Awaited<ReturnType<ReferenceOptions['filter']>>
60
59
  * by default instead of collapsed.
61
60
  *
62
61
  * @param props - Standard Sanity `ArrayFieldProps` extended with an optional
63
- * `embeddingsIndex` configuration object.
64
- * @param props.embeddingsIndex - Optional configuration for AI-assisted term
65
- * recommendations via a Sanity Embeddings Index. When provided, opening the tree
66
- * browser queries the specified index and annotates matching taxonomy terms with
67
- * a relevance score to help authors identify the most appropriate terms.
68
- * @param props.embeddingsIndex.indexName - The name of the Sanity Embeddings Index
69
- * to query. Must be an index that includes `skosConcept` documents.
70
- * @param props.embeddingsIndex.fieldReferences - An array of field names from the
71
- * current document whose values are concatenated and sent as the embeddings search
72
- * query. All listed fields must contain values when the tree browser is opened;
73
- * empty fields will display an error message in the tree view rather than scores.
74
- * @param props.embeddingsIndex.maxResults - Maximum number of semantically matching
75
- * terms to return from the embeddings index. Defaults to `3`.
62
+ * `semanticSearch` configuration object.
63
+ * @param props.semanticSearch - Optional configuration for AI-assisted term
64
+ * recommendations. When provided, opening the tree browser scores `skosConcept`
65
+ * documents against the dataset's embeddings (`text::semanticSimilarity()`) and
66
+ * annotates matching taxonomy terms with a relevance score to help authors
67
+ * identify the most appropriate terms. Requires dataset embeddings to be enabled.
68
+ * @param props.semanticSearch.fieldReferences - An array of field names from the
69
+ * current document whose values are concatenated and sent as the search query.
70
+ * All listed fields must contain values when the tree browser is opened; empty
71
+ * fields will display a message in the tree view rather than scores.
72
+ * @param props.semanticSearch.maxResults - Maximum number of semantically matching
73
+ * terms to return. Defaults to `3`.
76
74
  *
77
75
  * @example
78
76
  * Basic usage with a scheme filter:
@@ -166,8 +164,7 @@ type FilterResult = Awaited<ReturnType<ReferenceOptions['filter']>>
166
164
  * field: (props) => (
167
165
  * <ArrayHierarchyInput
168
166
  * {...props}
169
- * embeddingsIndex={{
170
- * indexName: 'my-taxonomy-index',
167
+ * semanticSearch={{
171
168
  * fieldReferences: ['title', 'description'],
172
169
  * maxResults: 4,
173
170
  * }}
@@ -187,7 +184,7 @@ export function ArrayHierarchyInput(props: ArrayHierarchyInputProps) {
187
184
  // the resource document in which the input component appears:
188
185
  const documentId = useFormValue(['_id']) as string
189
186
  // name of the field to input a value:
190
- const {name, title, value = [], embeddingsIndex} = props
187
+ const {name, title, value = [], semanticSearch} = props
191
188
 
192
189
  // Get release and draft status of the document
193
190
  const isInRelease = isVersionId(documentId as DocumentId)
@@ -209,7 +206,8 @@ export function ArrayHierarchyInput(props: ArrayHierarchyInputProps) {
209
206
 
210
207
  const {filter} = props.schemaType.of[0].options as ReferenceOptions
211
208
 
212
- const {conceptRecs, recsError, triggerEmbeddingsSearch} = useEmbeddingsRecs(embeddingsIndex)
209
+ const port = useTaxonomyDataPort()
210
+ const {conceptRecs, recsError, triggerSearch} = port.useSemanticRecommendations(semanticSearch)
213
211
 
214
212
  const toast = useToast()
215
213
 
@@ -244,8 +242,8 @@ export function ArrayHierarchyInput(props: ArrayHierarchyInputProps) {
244
242
 
245
243
  const browseHierarchy = useCallback(() => {
246
244
  setOpen(true)
247
- triggerEmbeddingsSearch()
248
- }, [triggerEmbeddingsSearch])
245
+ triggerSearch(schemeId)
246
+ }, [triggerSearch, schemeId])
249
247
 
250
248
  const handleClose = useCallback(() => {
251
249
  setOpen(false)
@@ -322,7 +320,7 @@ export function ArrayHierarchyInput(props: ArrayHierarchyInputProps) {
322
320
  })
323
321
  .catch((err) => console.error(err))
324
322
  },
325
- [value, isDraft, isInRelease, client, documentId, toast, name]
323
+ [value, isDraft, isInRelease, client, documentId, toast, name],
326
324
  )
327
325
 
328
326
  // Check to be sure a filter is present
@@ -414,6 +412,7 @@ export function ArrayHierarchyInput(props: ArrayHierarchyInputProps) {
414
412
  return (
415
413
  <FormField
416
414
  title={title}
415
+ path={props.path}
417
416
  description={props.description}
418
417
  level={props.level}
419
418
  validation={props.validation}
@@ -4,7 +4,7 @@ import {useCallback} from 'react'
4
4
  import {set} from 'sanity'
5
5
  import type {StringInputProps} from 'sanity'
6
6
 
7
- import {createId} from '../../helpers/createId'
7
+ import {createId} from '../../core/createId'
8
8
  import type {Options} from '../../types'
9
9
 
10
10
  type IdentifierProps = StringInputProps & {
@@ -0,0 +1,38 @@
1
+ import {screen} from '@testing-library/react'
2
+ import {describe, expect, it} from 'vitest'
3
+
4
+ import {ReleaseContext, SchemeContext} from '../../context'
5
+ import {TaxonomyPortProvider} from '../../seams/TaxonomyPortContext'
6
+ import {createFakeDataPort} from '../../test/FakeDataPort'
7
+ import {renderWithUi} from '../../test/renderWithUi'
8
+ import type {ConceptSchemeDocument} from '../../types'
9
+
10
+ import {InputHierarchy} from './InputHierarchy'
11
+
12
+ const scheme = {
13
+ displayed: {_id: 'scheme-9', _type: 'skosConceptScheme'},
14
+ } as unknown as ConceptSchemeDocument
15
+
16
+ describe('InputHierarchy (input watch seam)', () => {
17
+ it('watches the branch-scoped input tree with the resolved params', () => {
18
+ const port = createFakeDataPort({loading: true})
19
+
20
+ renderWithUi(
21
+ <TaxonomyPortProvider port={port}>
22
+ <SchemeContext.Provider value={scheme}>
23
+ <ReleaseContext.Provider value={'drafts'}>
24
+ <InputHierarchy branchId={'branch-1'} inputComponent selectConcept={() => undefined} />
25
+ </ReleaseContext.Provider>
26
+ </SchemeContext.Provider>
27
+ </TaxonomyPortProvider>
28
+ )
29
+
30
+ expect(screen.getByText('Loading hierarchy…')).toBeInTheDocument()
31
+ expect(port.lastWatchParams).toEqual({
32
+ mode: 'input',
33
+ documentId: 'scheme-9',
34
+ branchId: 'branch-1',
35
+ perspective: 'drafts',
36
+ })
37
+ })
38
+ })
@@ -1,19 +1,14 @@
1
- /* eslint-disable react/require-default-props */
2
1
  import type {DocumentId} from '@sanity/id-utils'
3
2
  import {getPublishedId} from '@sanity/id-utils'
4
3
  import {Flex, Spinner, Box, Text, Card} from '@sanity/ui'
5
4
  import {nanoid} from 'nanoid'
6
- import {useCallback, useContext, useMemo} from 'react'
7
- import {useListeningQuery} from 'sanity-plugin-utils'
5
+ import {useContext, useMemo} from 'react'
8
6
 
9
7
  import {ReleaseContext, SchemeContext, TreeContext} from '../../context'
10
- import {inputBuilder} from '../../queries'
11
- import type {
12
- ChildConceptTerm,
13
- ConceptSchemeDocument,
14
- DocumentConcepts,
15
- TreeViewProps,
16
- } from '../../types'
8
+ import {recommendedConceptIds} from '../../core/semanticRecommendations'
9
+ import {annotateRecommendations} from '../../core/tree/annotateRecommendations'
10
+ import {useTaxonomyDataPort} from '../../seams/TaxonomyPortContext'
11
+ import type {ConceptSchemeDocument, TreeViewProps} from '../../types'
17
12
  import {TreeStructure} from '../TreeStructure'
18
13
 
19
14
  /**
@@ -34,73 +29,30 @@ export const InputHierarchy = ({
34
29
  }: TreeViewProps) => {
35
30
  const document: ConceptSchemeDocument = useContext(SchemeContext) || ({} as ConceptSchemeDocument)
36
31
  const documentId = getPublishedId(document.displayed?._id as DocumentId)
37
- const releaseContext: string = useContext(ReleaseContext) as string
32
+ const releaseContext = useContext(ReleaseContext)
38
33
  const initialVisibility = expanded ? 'open' : 'closed'
39
- const {data, loading, error} = useListeningQuery<DocumentConcepts>(
40
- {
41
- fetch: inputBuilder(),
42
- listen: `*[_type == "skosConcept" || _id == $id]`,
43
- },
44
- {
45
- // GROQ's select($branchId != null => ...) requires null, not empty string.
46
- // ListenQueryParams doesn't accept null, so we cast past it.
47
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
48
- params: {id: documentId, branchId} as any,
49
- options: {
50
- perspective: releaseContext === undefined ? 'drafts' : [releaseContext],
51
- },
52
- }
53
- ) as {data: DocumentConcepts; loading: boolean; error: Error | null}
34
+ const port = useTaxonomyDataPort()
35
+ const {data, loading, error} = port.useWatchTree({
36
+ mode: 'input',
37
+ documentId,
38
+ branchId,
39
+ perspective: releaseContext,
40
+ })
54
41
 
55
- // Build a score lookup map from conceptRecs:
56
- const scoreMap = useMemo(() => {
57
- const map = new Map<string, number>()
58
- if (Array.isArray(conceptRecs)) {
59
- for (const rec of conceptRecs) {
60
- map.set(rec.value.documentId, rec.score)
61
- }
62
- }
63
- return map
64
- }, [conceptRecs])
42
+ // Mark which concepts are recommendations (by published id):
43
+ const recommendedIds = useMemo(() => recommendedConceptIds(conceptRecs ?? []), [conceptRecs])
65
44
 
66
45
  // eslint-disable-next-line react-hooks/exhaustive-deps
67
- const treeId = useMemo(() => nanoid(6), [scoreMap])
68
-
69
- // Recursively annotate tree nodes
70
- // Walk the tree creating new objects with score attached where
71
- // there's a match. Use getPublishedId() to normalize the tree
72
- // node id before comparing against the lookup map.
73
- // Process children first, then check if any child has score
74
- // or hasMatchingDescendant.
75
- const addScores = useCallback(function addScores<T extends ChildConceptTerm>(
76
- node: T,
77
- scores: Map<string, number>
78
- ): T {
79
- const publishedId = getPublishedId(node.id as DocumentId)
80
- const score = scores.get(publishedId)
81
-
82
- const annotatedChildren = node.childConcepts?.map((c) => addScores(c, scores))
83
-
84
- const hasMatchingDescendant =
85
- annotatedChildren?.some((c) => c.score !== undefined || c.hasMatchingDescendant) ?? false
86
-
87
- return {
88
- ...node,
89
- ...(score === undefined ? {} : {score}),
90
- ...(hasMatchingDescendant ? {hasMatchingDescendant: true} : {}),
91
- ...(annotatedChildren ? {childConcepts: annotatedChildren} : {}),
92
- } as T
93
- },
94
- [])
46
+ const treeId = useMemo(() => nanoid(6), [recommendedIds])
95
47
 
96
48
  // Compute merged data with useMemo:
97
49
  const mergedData = useMemo(() => {
98
- if (!data || scoreMap.size === 0 || recsError) return data
50
+ if (!data || recommendedIds.size === 0 || recsError) return data
99
51
  return {
100
- topConcepts: data.topConcepts?.map((tc) => addScores(tc, scoreMap)),
101
- concepts: data.concepts?.map((c) => addScores(c, scoreMap)),
52
+ topConcepts: data.topConcepts?.map((tc) => annotateRecommendations(tc, recommendedIds)),
53
+ concepts: data.concepts?.map((c) => annotateRecommendations(c, recommendedIds)),
102
54
  }
103
- }, [addScores, data, scoreMap, recsError])
55
+ }, [data, recommendedIds, recsError])
104
56
 
105
57
  if (loading) {
106
58
  return (
@@ -0,0 +1,109 @@
1
+ import {screen} from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import type {ReactNode} from 'react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {TaxonomyPortProvider} from '../../seams/TaxonomyPortContext'
7
+ import {createFakeDataPort} from '../../test/FakeDataPort'
8
+ import {fakeSchemeClient, filterFor, referenceFieldProps} from '../../test/inputHarness'
9
+ import {renderWithUi} from '../../test/renderWithUi'
10
+
11
+ import {ReferenceHierarchyInput} from './ReferenceHierarchyInput'
12
+
13
+ // jsdom has no Studio runtime, so the Studio hooks the input pulls from `sanity`
14
+ // are replaced with controllable fakes and FormField with a passthrough. The data
15
+ // seam goes through an injected FakeDataPort, so the real recommendations hook
16
+ // (which also reads the client) is never reached. isDraftId/isVersionId stay real.
17
+ const mocks = vi.hoisted(() => ({
18
+ useClient: vi.fn(),
19
+ useFormValue: vi.fn(),
20
+ usePerspective: vi.fn(),
21
+ }))
22
+
23
+ vi.mock('sanity', async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import('sanity')>()
25
+ return {
26
+ ...actual,
27
+ useClient: mocks.useClient,
28
+ useFormValue: mocks.useFormValue,
29
+ usePerspective: mocks.usePerspective,
30
+ FormField: ({title, children}: {title?: string; children?: ReactNode}) => (
31
+ <div data-testid="form-field">
32
+ {title}
33
+ {children}
34
+ </div>
35
+ ),
36
+ }
37
+ })
38
+
39
+ function renderInput(
40
+ props = referenceFieldProps(),
41
+ port = createFakeDataPort()
42
+ ) {
43
+ return renderWithUi(
44
+ <TaxonomyPortProvider port={port}>
45
+ <ReferenceHierarchyInput {...props} />
46
+ </TaxonomyPortProvider>
47
+ )
48
+ }
49
+
50
+ beforeEach(() => {
51
+ vi.clearAllMocks()
52
+ mocks.useClient.mockReturnValue(fakeSchemeClient())
53
+ mocks.useFormValue.mockReturnValue('doc-1')
54
+ mocks.usePerspective.mockReturnValue({selectedPerspectiveName: undefined})
55
+ })
56
+
57
+ describe('ReferenceHierarchyInput', () => {
58
+ it('warns when the field is configured without a filter', () => {
59
+ renderInput(referenceFieldProps({schemaType: {options: {}}}))
60
+
61
+ expect(screen.getByText(/must be used with an accompanying/i)).toBeInTheDocument()
62
+ })
63
+
64
+ it('disables the browse button on the published perspective', async () => {
65
+ mocks.usePerspective.mockReturnValue({selectedPerspectiveName: 'published'})
66
+ renderInput()
67
+
68
+ expect(await screen.findByRole('button', {name: /browse taxonomy tree/i})).toBeDisabled()
69
+ })
70
+
71
+ it('enables the browse button off the published perspective', async () => {
72
+ renderInput()
73
+
74
+ expect(await screen.findByRole('button', {name: /browse taxonomy tree/i})).toBeEnabled()
75
+ })
76
+
77
+ it('suppresses the default input in browse-only mode, showing an empty preview', async () => {
78
+ renderInput(
79
+ referenceFieldProps({
80
+ schemaType: {options: {filter: filterFor({schemeId: 'scheme-x'}, {browseOnly: true})}},
81
+ })
82
+ )
83
+
84
+ expect(await screen.findByText('No items')).toBeInTheDocument()
85
+ expect(screen.queryByTestId('default-input')).not.toBeInTheDocument()
86
+ })
87
+
88
+ it('renders the default input when not browse-only', async () => {
89
+ renderInput()
90
+
91
+ expect(await screen.findByTestId('default-input')).toBeInTheDocument()
92
+ })
93
+
94
+ it('runs a scheme-scoped search on browse and badges recommended concepts', async () => {
95
+ const port = createFakeDataPort({
96
+ tree: {topConcepts: [{id: 'concept-1', prefLabel: 'Biology', _originalId: 'concept-1'}], concepts: []},
97
+ recommendations: [{conceptId: 'concept-1', score: 0.9}],
98
+ })
99
+ renderInput(referenceFieldProps(), port)
100
+
101
+ await userEvent.setup().click(await screen.findByRole('button', {name: /browse taxonomy tree/i}))
102
+
103
+ // The search is triggered scoped to the field's resolved scheme...
104
+ expect(port.lastSearchSchemeId).toBe('scheme-x')
105
+ // ...and the recommended concept is badged in the opened tree.
106
+ expect(await screen.findByText('Biology')).toBeInTheDocument()
107
+ expect(await screen.findByText('recommended')).toBeInTheDocument()
108
+ })
109
+ })
@@ -1,4 +1,3 @@
1
- /* eslint-disable react/require-default-props */
2
1
  import {isVersionId} from '@sanity/id-utils'
3
2
  import type {DocumentId} from '@sanity/id-utils'
4
3
  import {Grid, Stack, Button, Dialog, Box, Spinner, Text, Flex, Card} from '@sanity/ui'
@@ -6,9 +5,9 @@ import {useState, useEffect, useCallback} from 'react'
6
5
  import type {ObjectFieldProps, ObjectOptions, Reference} from 'sanity'
7
6
  import {FormField, isDraftId, useClient, useFormValue, usePerspective} from 'sanity'
8
7
 
9
- import {useEmbeddingsRecs} from '../../hooks'
8
+ import {useTaxonomyDataPort} from '../../seams/TaxonomyPortContext'
10
9
  import NodeTree from '../../static/NodeTree'
11
- import type {ConceptSchemeDocument, EmbeddingsIndexConfig} from '../../types'
10
+ import type {ConceptSchemeDocument, SemanticSearchConfig} from '../../types'
12
11
  import {TreeView} from '../TreeView'
13
12
 
14
13
  type ReferenceOptions = ObjectOptions & {
@@ -26,7 +25,7 @@ type ReferenceOptions = ObjectOptions & {
26
25
  }
27
26
 
28
27
  type HierarchyInput = ObjectFieldProps<Reference> & {
29
- embeddingsIndex?: EmbeddingsIndexConfig
28
+ semanticSearch?: SemanticSearchConfig
30
29
  }
31
30
 
32
31
  // Extract the return type of the filter function
@@ -47,19 +46,18 @@ type FilterResult = Awaited<ReturnType<ReferenceOptions['filter']>>
47
46
  * by default instead of collapsed.
48
47
  *
49
48
  * @param props - Standard Sanity `ObjectFieldProps<Reference>` extended with an optional
50
- * `embeddingsIndex` configuration object.
51
- * @param props.embeddingsIndex - Optional configuration for AI-assisted term
52
- * recommendations via a Sanity Embeddings Index. When provided, opening the tree
53
- * browser queries the specified index and annotates matching taxonomy terms with
54
- * a relevance score to help authors identify the most appropriate term.
55
- * @param props.embeddingsIndex.indexName - The name of the Sanity Embeddings Index
56
- * to query. Must be an index that includes `skosConcept` documents.
57
- * @param props.embeddingsIndex.fieldReferences - An array of field names from the
58
- * current document whose values are concatenated and sent as the embeddings search
59
- * query. All listed fields must contain values when the tree browser is opened;
60
- * empty fields will display an error message in the tree view rather than scores.
61
- * @param props.embeddingsIndex.maxResults - Maximum number of semantically matching
62
- * terms to return from the embeddings index. Defaults to `3`.
49
+ * `semanticSearch` configuration object.
50
+ * @param props.semanticSearch - Optional configuration for AI-assisted term
51
+ * recommendations. When provided, opening the tree browser scores `skosConcept`
52
+ * documents against the dataset's embeddings (`text::semanticSimilarity()`) and
53
+ * annotates matching taxonomy terms with a relevance score to help authors
54
+ * identify the most appropriate term. Requires dataset embeddings to be enabled.
55
+ * @param props.semanticSearch.fieldReferences - An array of field names from the
56
+ * current document whose values are concatenated and sent as the search query.
57
+ * All listed fields must contain values when the tree browser is opened; empty
58
+ * fields will display a message in the tree view rather than scores.
59
+ * @param props.semanticSearch.maxResults - Maximum number of semantically matching
60
+ * terms to return. Defaults to `3`.
63
61
  *
64
62
  * @example
65
63
  * Basic usage with a scheme filter:
@@ -116,7 +114,7 @@ type FilterResult = Awaited<ReturnType<ReferenceOptions['filter']>>
116
114
  * ```
117
115
  *
118
116
  * @example
119
- * AI-assisted recommendations via an embeddings index:
117
+ * AI-assisted recommendations via dataset embeddings:
120
118
  * ```jsx
121
119
  * import {ReferenceHierarchyInput, schemeFilter} from 'sanity-plugin-taxonomy-manager'
122
120
  *
@@ -133,8 +131,7 @@ type FilterResult = Awaited<ReturnType<ReferenceOptions['filter']>>
133
131
  * field: (props) => (
134
132
  * <ReferenceHierarchyInput
135
133
  * {...props}
136
- * embeddingsIndex={{
137
- * indexName: 'my-taxonomy-index',
134
+ * semanticSearch={{
138
135
  * fieldReferences: ['title', 'metaDescription'],
139
136
  * maxResults: 4,
140
137
  * }}
@@ -155,9 +152,10 @@ export function ReferenceHierarchyInput(props: HierarchyInput) {
155
152
  const documentId = useFormValue(['_id']) as string
156
153
 
157
154
  // name of the field to input a value
158
- const {name, title, value, embeddingsIndex} = props
155
+ const {name, title, value, semanticSearch} = props
159
156
 
160
- const {conceptRecs, recsError, triggerEmbeddingsSearch} = useEmbeddingsRecs(embeddingsIndex)
157
+ const port = useTaxonomyDataPort()
158
+ const {conceptRecs, recsError, triggerSearch} = port.useSemanticRecommendations(semanticSearch)
161
159
 
162
160
  // Get release and draft status of the document
163
161
  const isInRelease = isVersionId(documentId as DocumentId)
@@ -212,8 +210,8 @@ export function ReferenceHierarchyInput(props: HierarchyInput) {
212
210
 
213
211
  const browseHierarchy = useCallback(() => {
214
212
  setOpen(true)
215
- triggerEmbeddingsSearch()
216
- }, [triggerEmbeddingsSearch])
213
+ triggerSearch(schemeId)
214
+ }, [triggerSearch, schemeId])
217
215
 
218
216
  const handleClose = useCallback(() => {
219
217
  setOpen(false)
@@ -268,7 +266,7 @@ export function ReferenceHierarchyInput(props: HierarchyInput) {
268
266
  .then(() => setOpen(false))
269
267
  .catch((err) => console.error(err))
270
268
  },
271
- [client, documentId, isDraft, isInRelease, name]
269
+ [client, documentId, isDraft, isInRelease, name],
272
270
  )
273
271
 
274
272
  // Check to be sure a filter is present
@@ -337,6 +335,7 @@ export function ReferenceHierarchyInput(props: HierarchyInput) {
337
335
  return (
338
336
  <FormField
339
337
  title={title}
338
+ path={props.path}
340
339
  description={props.description}
341
340
  level={props.level}
342
341
  validation={props.validation}
@@ -1,4 +1,3 @@
1
- /* eslint-disable react/require-default-props */
2
1
  import {Button, Text, Box, Badge, Tooltip} from '@sanity/ui'
3
2
  import {useCallback, useContext} from 'react'
4
3
  import {RouterContext} from 'sanity/router'
@@ -44,7 +44,7 @@ export const ConceptEditAction = ({
44
44
  mode={'bleed'}
45
45
  onClick={handler}
46
46
  tone={typeMap[action].tone}
47
- aria-label="Remove this concept from this scheme"
47
+ aria-label={typeMap[action].message}
48
48
  />
49
49
  </Tooltip>
50
50
  )
@@ -0,0 +1,49 @@
1
+ import {screen} from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import {describe, expect, it, vi} from 'vitest'
4
+
5
+ import {renderWithUi} from '../../test/renderWithUi'
6
+ import type {ChildConceptTerm} from '../../types'
7
+
8
+ import {ConceptSelectLink} from './ConceptSelectLink'
9
+
10
+ const concept = (over: Partial<ChildConceptTerm> = {}): ChildConceptTerm => ({
11
+ id: 'concept-1',
12
+ prefLabel: 'Mathematics',
13
+ ...over,
14
+ })
15
+
16
+ describe('ConceptSelectLink (recommendation display)', () => {
17
+ it('shows a "recommended" badge, never a percentage', () => {
18
+ renderWithUi(
19
+ <ConceptSelectLink concept={concept({recommended: true})} selectConcept={() => undefined} />
20
+ )
21
+
22
+ expect(screen.getByText('recommended')).toBeInTheDocument()
23
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument()
24
+ })
25
+
26
+ it('shows no recommendation badge when the concept is not recommended', () => {
27
+ renderWithUi(<ConceptSelectLink concept={concept()} selectConcept={() => undefined} />)
28
+
29
+ expect(screen.queryByText('recommended')).not.toBeInTheDocument()
30
+ })
31
+
32
+ it('writes the concept reference when clicked', async () => {
33
+ const selectConcept = vi.fn()
34
+ renderWithUi(
35
+ <ConceptSelectLink
36
+ concept={concept({id: 'concept-9', _originalId: 'drafts.concept-9'})}
37
+ selectConcept={selectConcept}
38
+ />
39
+ )
40
+
41
+ await userEvent.setup().click(screen.getByRole('button', {name: /Mathematics/}))
42
+
43
+ expect(selectConcept).toHaveBeenCalledWith({
44
+ _ref: 'concept-9',
45
+ _type: 'reference',
46
+ _originalId: 'drafts.concept-9',
47
+ })
48
+ })
49
+ })
@@ -1,4 +1,3 @@
1
- /* eslint-disable react/require-default-props */
2
1
  import {Button, Text, Box, Badge, Tooltip} from '@sanity/ui'
3
2
  import {useCallback} from 'react'
4
3
 
@@ -23,7 +22,7 @@ export function ConceptSelectLink({
23
22
  _originalId: string | undefined
24
23
  }) => void
25
24
  }) {
26
- const {prefLabel, id, _originalId, score} = concept ?? {}
25
+ const {prefLabel, id, _originalId, recommended} = concept ?? {}
27
26
  const displayLabel = prefLabel || '[new concept]'
28
27
 
29
28
  const handleClick = useCallback(() => {
@@ -46,7 +45,7 @@ export function ConceptSelectLink({
46
45
  <Box padding={1} sizing="content">
47
46
  <Text muted size={1}>
48
47
  {`Select "${prefLabel}"`}
49
- {score && ` (${(score * 100).toFixed(1)}% resource match)`}
48
+ {recommended && ` (recommended)`}
50
49
  </Text>
51
50
  </Box>
52
51
  }
@@ -68,9 +67,14 @@ export function ConceptSelectLink({
68
67
  {topConcept ? 'top concept' : 'orphan'}
69
68
  </Badge>
70
69
  )}
71
- {score && (
72
- <Badge fontSize={0} marginLeft={3} style={{verticalAlign: 'middle'}}>
73
- {`${(score * 100).toFixed(1)}%`}
70
+ {recommended && (
71
+ <Badge
72
+ tone="positive"
73
+ fontSize={0}
74
+ marginLeft={3}
75
+ style={{verticalAlign: 'middle'}}
76
+ >
77
+ recommended
74
78
  </Badge>
75
79
  )}
76
80
  </Text>