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,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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
16
|
-
import {createId} from '../
|
|
17
|
-
import
|
|
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
|
-
*
|
|
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
|
-
//
|
|
32
|
-
const
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
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
|
|
26
|
+
const plan = planRemoveConcept({
|
|
27
|
+
scheme: document.displayed,
|
|
28
|
+
conceptRef: conceptId,
|
|
29
|
+
conceptType,
|
|
30
|
+
})
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
[
|
|
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 {
|
|
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
|
+
}
|