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.
- package/LICENSE +1 -1
- package/README.md +13 -108
- package/{lib → dist}/index.d.ts +66 -48
- package/dist/index.js +106 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -46
- package/src/components/Children.tsx +4 -3
- package/src/components/Concepts.tsx +2 -2
- package/src/components/Hierarchy.test.tsx +92 -0
- package/src/components/Hierarchy.tsx +65 -30
- package/src/components/TopConcepts.tsx +2 -2
- package/src/components/TreeStructure.tsx +3 -3
- package/src/components/inputs/ArrayHierarchyInput.test.tsx +102 -0
- package/src/components/inputs/ArrayHierarchyInput.tsx +23 -24
- package/src/components/inputs/Identifier.tsx +1 -1
- package/src/components/inputs/InputHierarchy.test.tsx +38 -0
- package/src/components/inputs/InputHierarchy.tsx +20 -68
- package/src/components/inputs/ReferenceHierarchyInput.test.tsx +109 -0
- package/src/components/inputs/ReferenceHierarchyInput.tsx +24 -25
- package/src/components/interactions/ConceptDetailLink.tsx +0 -1
- package/src/components/interactions/ConceptEditAction.tsx +1 -1
- package/src/components/interactions/ConceptSelectLink.test.tsx +49 -0
- package/src/components/interactions/ConceptSelectLink.tsx +10 -6
- package/src/context.ts +34 -14
- package/src/core/__snapshots__/queries.test.ts.snap +386 -0
- package/src/core/__snapshots__/semanticRecommendations.test.ts.snap +10 -0
- package/src/core/createId.test.ts +35 -0
- package/src/core/filters.test.ts +64 -0
- package/src/core/filters.ts +68 -0
- package/src/core/ids.test.ts +68 -0
- package/src/core/ids.ts +61 -0
- package/src/core/mutations.test.ts +114 -0
- package/src/core/mutations.ts +105 -0
- package/src/core/ports.ts +59 -0
- package/src/core/queries.test.ts +50 -0
- package/src/{queries.ts → core/queries.ts} +7 -6
- package/src/core/semanticRecommendations.test.ts +160 -0
- package/src/core/semanticRecommendations.ts +127 -0
- package/src/core/tree/annotateRecommendations.test.ts +43 -0
- package/src/core/tree/annotateRecommendations.ts +30 -0
- package/src/core/tree/pruneConcepts.test.ts +77 -0
- package/src/core/tree/pruneConcepts.ts +72 -0
- package/src/core/validation.test.ts +42 -0
- package/src/core/validation.ts +42 -0
- package/src/helpers/baseIriField.ts +22 -0
- package/src/helpers/branchFilter.test.ts +48 -0
- package/src/helpers/branchFilter.ts +17 -26
- package/src/helpers/schemeFilter.test.ts +51 -0
- package/src/helpers/schemeFilter.ts +11 -23
- package/src/hooks/index.ts +1 -1
- package/src/hooks/useCreateConcept.tsx +33 -110
- package/src/hooks/useRemoveConcept.tsx +24 -36
- package/src/hooks/useSemanticRecommendations.tsx +75 -0
- package/src/index.test.ts +31 -0
- package/src/index.ts +2 -6
- package/src/seams/StudioDataAdapter.ts +81 -0
- package/src/seams/TaxonomyPortContext.tsx +33 -0
- package/src/skosConcept.tsx +23 -234
- package/src/skosConceptScheme.tsx +2 -4
- package/src/structure.tsx +60 -0
- package/src/test/FakeDataPort.test.ts +62 -0
- package/src/test/FakeDataPort.ts +69 -0
- package/src/test/inputHarness.tsx +71 -0
- package/src/test/renderWithUi.test.tsx +14 -0
- package/src/test/renderWithUi.tsx +24 -0
- package/src/test-setup.ts +35 -0
- package/src/types.tsx +30 -11
- package/src/views/ConceptUseView.tsx +4 -10
- package/lib/index.esm.d.mts +0 -492
- package/lib/index.esm.esm.js +0 -182
- package/lib/index.esm.esm.js.map +0 -1
- package/lib/index.esm.mjs +0 -182
- package/lib/index.esm.mjs.map +0 -1
- package/lib/index.js +0 -182
- package/lib/index.js.map +0 -1
- package/sanity.json +0 -8
- package/src/config.ts +0 -16
- package/src/helpers/baseIriField.tsx +0 -42
- package/src/hooks/useEmbeddingsRecs.tsx +0 -75
- package/src/skosConcept.module.css +0 -10
- package/src/structure.ts +0 -40
- package/v2-incompatible.js +0 -11
- /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 {
|
|
19
|
+
import {useTaxonomyDataPort} from '../../seams/TaxonomyPortContext'
|
|
21
20
|
import NodeTree from '../../static/NodeTree'
|
|
22
|
-
import type {ConceptSchemeDocument,
|
|
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
|
-
|
|
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
|
-
* `
|
|
64
|
-
* @param props.
|
|
65
|
-
* recommendations
|
|
66
|
-
*
|
|
67
|
-
* a relevance score to help authors
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
*
|
|
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 = [],
|
|
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
|
|
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
|
-
|
|
248
|
-
}, [
|
|
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 '../../
|
|
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 {
|
|
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 {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
32
|
+
const releaseContext = useContext(ReleaseContext)
|
|
38
33
|
const initialVisibility = expanded ? 'open' : 'closed'
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
56
|
-
const
|
|
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), [
|
|
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 ||
|
|
50
|
+
if (!data || recommendedIds.size === 0 || recsError) return data
|
|
99
51
|
return {
|
|
100
|
-
topConcepts: data.topConcepts?.map((tc) =>
|
|
101
|
-
concepts: data.concepts?.map((c) =>
|
|
52
|
+
topConcepts: data.topConcepts?.map((tc) => annotateRecommendations(tc, recommendedIds)),
|
|
53
|
+
concepts: data.concepts?.map((c) => annotateRecommendations(c, recommendedIds)),
|
|
102
54
|
}
|
|
103
|
-
}, [
|
|
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 {
|
|
8
|
+
import {useTaxonomyDataPort} from '../../seams/TaxonomyPortContext'
|
|
10
9
|
import NodeTree from '../../static/NodeTree'
|
|
11
|
-
import type {ConceptSchemeDocument,
|
|
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
|
-
|
|
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
|
-
* `
|
|
51
|
-
* @param props.
|
|
52
|
-
* recommendations
|
|
53
|
-
*
|
|
54
|
-
* a relevance score to help authors
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
155
|
+
const {name, title, value, semanticSearch} = props
|
|
159
156
|
|
|
160
|
-
const
|
|
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
|
-
|
|
216
|
-
}, [
|
|
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}
|
|
@@ -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,
|
|
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
|
-
{
|
|
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
|
-
{
|
|
72
|
-
<Badge
|
|
73
|
-
|
|
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>
|