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
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-taxonomy-manager",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "Create and manage SKOS compliant taxonomies, thesauri, and classification schemes in Sanity Studio.",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"sanity",
|
|
@@ -24,89 +25,82 @@
|
|
|
24
25
|
"exports": {
|
|
25
26
|
".": {
|
|
26
27
|
"source": "./src/index.ts",
|
|
27
|
-
"import": "./
|
|
28
|
-
"
|
|
29
|
-
"default": "./lib/index.esm.mjs"
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
30
|
},
|
|
31
31
|
"./package.json": "./package.json"
|
|
32
32
|
},
|
|
33
|
-
"main": "./
|
|
34
|
-
"
|
|
35
|
-
"types": "./lib/index.d.ts",
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
36
35
|
"files": [
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"src",
|
|
40
|
-
"v2-incompatible.js"
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
41
38
|
],
|
|
42
39
|
"scripts": {
|
|
43
|
-
"
|
|
44
|
-
"build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
|
|
45
|
-
"clean": "rimraf lib",
|
|
46
|
-
"link-watch": "plugin-kit link-watch",
|
|
47
|
-
"lint": "eslint .",
|
|
48
|
-
"prepublishOnly": "npm run build",
|
|
40
|
+
"build": "pkg-utils build --strict --check --clean",
|
|
49
41
|
"watch": "pkg-utils watch --strict",
|
|
42
|
+
"dev": "pnpm --filter studio dev",
|
|
43
|
+
"lint": "eslint .",
|
|
50
44
|
"format": "prettier --write --cache --ignore-unknown .",
|
|
51
|
-
"test": "vitest",
|
|
52
|
-
"test:
|
|
53
|
-
"
|
|
54
|
-
"
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"prepare": "husky",
|
|
49
|
+
"prepublishOnly": "pnpm run build"
|
|
55
50
|
},
|
|
56
51
|
"dependencies": {
|
|
57
52
|
"@sanity/color": "^3.0.0",
|
|
58
53
|
"@sanity/icons": "^3.4.0",
|
|
59
54
|
"@sanity/id-utils": "^1.0.0",
|
|
60
|
-
"@sanity/
|
|
61
|
-
"@sanity/ui": "^2.8.9",
|
|
55
|
+
"@sanity/uuid": "^3.0.2",
|
|
62
56
|
"nanoid": "^5.1.5",
|
|
63
57
|
"react-fast-compare": "^3.2.2",
|
|
64
58
|
"react-icons": "^5.5.0",
|
|
65
|
-
"rxjs": "^7.8.1"
|
|
66
|
-
"styled-components": "^6.1.19"
|
|
59
|
+
"rxjs": "^7.8.1"
|
|
67
60
|
},
|
|
68
61
|
"devDependencies": {
|
|
62
|
+
"@commitlint/cli": "^21.0.2",
|
|
63
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
64
|
+
"@sanity/eslint-config-studio": "^6.0.0",
|
|
69
65
|
"@sanity/pkg-utils": "^6.10.10",
|
|
70
|
-
"@sanity/
|
|
66
|
+
"@sanity/semantic-release-preset": "^6.0.0",
|
|
67
|
+
"@sanity/ui": "^3.2.0",
|
|
71
68
|
"@testing-library/dom": "^10.4.0",
|
|
72
69
|
"@testing-library/jest-dom": "^6.6.3",
|
|
73
70
|
"@testing-library/react": "^16.3.0",
|
|
74
71
|
"@testing-library/user-event": "^14.6.1",
|
|
75
72
|
"@types/react": "^19.1.13",
|
|
76
73
|
"@types/react-dom": "^19.1.9",
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"eslint": "^8.38.0",
|
|
82
|
-
"eslint-config-prettier": "^8.8.0",
|
|
83
|
-
"eslint-config-sanity": "^6.0.0",
|
|
84
|
-
"eslint-plugin-import": "^2.32.0",
|
|
85
|
-
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
86
|
-
"eslint-plugin-prettier": "^4.2.1",
|
|
87
|
-
"eslint-plugin-react": "^7.32.2",
|
|
88
|
-
"eslint-plugin-react-hooks": "^4.6.0",
|
|
74
|
+
"eslint": "^9.39.4",
|
|
75
|
+
"eslint-config-prettier": "^10.1.8",
|
|
76
|
+
"globals": "^17.6.0",
|
|
77
|
+
"husky": "^9.1.7",
|
|
89
78
|
"jsdom": "^26.1.0",
|
|
90
|
-
"
|
|
91
|
-
"prettier": "^
|
|
79
|
+
"lint-staged": "^17.0.7",
|
|
80
|
+
"prettier": "^3.8.4",
|
|
81
|
+
"prettier-plugin-packagejson": "^3.0.2",
|
|
92
82
|
"react": "^19.2.3",
|
|
93
83
|
"react-dom": "^19.2.3",
|
|
94
84
|
"react-is": "^18.2.0",
|
|
95
|
-
"rimraf": "^5.0.5",
|
|
96
85
|
"rollup-plugin-postcss": "^4.0.2",
|
|
97
|
-
"sanity": "^
|
|
98
|
-
"sanity-plugin-utils": "^
|
|
86
|
+
"sanity": "^6.6.0",
|
|
87
|
+
"sanity-plugin-utils": "^2.0.3",
|
|
88
|
+
"semantic-release": "^25.0.5",
|
|
89
|
+
"styled-components": "^6.1.19",
|
|
99
90
|
"typescript": "^5.9.2",
|
|
100
91
|
"vitest": "^3.2.4"
|
|
101
92
|
},
|
|
102
93
|
"peerDependencies": {
|
|
103
|
-
"
|
|
104
|
-
"react
|
|
105
|
-
"
|
|
94
|
+
"@sanity/ui": "^3",
|
|
95
|
+
"react": "^19",
|
|
96
|
+
"react-dom": "^19",
|
|
97
|
+
"sanity": "^5 || ^6",
|
|
98
|
+
"styled-components": "^5.2 || ^6"
|
|
106
99
|
},
|
|
107
100
|
"engines": {
|
|
108
101
|
"node": ">=20"
|
|
109
102
|
},
|
|
103
|
+
"packageManager": "pnpm@10.11.0",
|
|
110
104
|
"sanityExchangeUrl": "https://www.sanity.io/plugins/taxonomy-manager",
|
|
111
105
|
"sanityPlugin": {
|
|
112
106
|
"verifyPackage": {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable complexity */
|
|
2
1
|
import {Inline, Box, Flex} from '@sanity/ui'
|
|
3
2
|
import {useCallback, useContext, useState} from 'react'
|
|
4
3
|
|
|
@@ -33,7 +32,7 @@ export const Children = ({
|
|
|
33
32
|
inputComponent: boolean
|
|
34
33
|
}) => {
|
|
35
34
|
const document: ConceptSchemeDocument = useContext(SchemeContext) || ({} as ConceptSchemeDocument)
|
|
36
|
-
const releaseContext
|
|
35
|
+
const releaseContext = useContext(ReleaseContext)
|
|
37
36
|
const {globalVisibility: {treeVisibility} = {treeVisibility: 'open' as const}} =
|
|
38
37
|
useContext(TreeContext) || {}
|
|
39
38
|
const createConcept = useCreateConcept(document)
|
|
@@ -48,7 +47,9 @@ export const Children = ({
|
|
|
48
47
|
}, [concept.id, concept?.prefLabel, removeConcept])
|
|
49
48
|
|
|
50
49
|
const [levelVisibility, setLevelVisibility] = useState<'open' | 'closed'>(
|
|
51
|
-
treeVisibility === 'closed' && concept.hasMatchingDescendant
|
|
50
|
+
treeVisibility === 'closed' && concept.hasMatchingDescendant
|
|
51
|
+
? 'open'
|
|
52
|
+
: treeVisibility || 'open',
|
|
52
53
|
)
|
|
53
54
|
|
|
54
55
|
const handleToggle = useCallback(() => {
|
|
@@ -34,7 +34,7 @@ export const Concepts = ({
|
|
|
34
34
|
selectConcept,
|
|
35
35
|
}: ConceptProps) => {
|
|
36
36
|
const document: ConceptSchemeDocument = useContext(SchemeContext) || ({} as ConceptSchemeDocument)
|
|
37
|
-
const releaseContext
|
|
37
|
+
const releaseContext = useContext(ReleaseContext)
|
|
38
38
|
|
|
39
39
|
const hasTopConcept = !!document.displayed?.topConcepts?.length && !inputComponent
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ export const Concepts = ({
|
|
|
42
42
|
const removeConcept = useRemoveConcept(document)
|
|
43
43
|
|
|
44
44
|
const [levelVisibility, setLevelVisibility] = useState(
|
|
45
|
-
treeVisibility === 'closed' && concept.hasMatchingDescendant ? 'open' : treeVisibility
|
|
45
|
+
treeVisibility === 'closed' && concept.hasMatchingDescendant ? 'open' : treeVisibility,
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
const handleToggle = useCallback(() => {
|
|
@@ -0,0 +1,92 @@
|
|
|
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 {ReleaseContext, SchemeContext} from '../context'
|
|
6
|
+
import {TaxonomyPortProvider} from '../seams/TaxonomyPortContext'
|
|
7
|
+
import {createFakeDataPort} from '../test/FakeDataPort'
|
|
8
|
+
import {renderWithUi} from '../test/renderWithUi'
|
|
9
|
+
import type {ConceptSchemeDocument, DocumentConcepts} from '../types'
|
|
10
|
+
|
|
11
|
+
import {Hierarchy} from './Hierarchy'
|
|
12
|
+
|
|
13
|
+
// Concept links + new-pane navigation read Studio's pane router, which isn't
|
|
14
|
+
// present in jsdom. Stub it — navigation isn't what these tests exercise.
|
|
15
|
+
vi.mock('sanity/structure', async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import('sanity/structure')>()),
|
|
17
|
+
usePaneRouter: () => ({routerPanesState: [], groupIndex: 0}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const scheme = {
|
|
21
|
+
displayed: {_id: 'scheme-1', _type: 'skosConceptScheme', title: 'Test Scheme'},
|
|
22
|
+
} as unknown as ConceptSchemeDocument
|
|
23
|
+
|
|
24
|
+
function renderHierarchy(
|
|
25
|
+
port: ReturnType<typeof createFakeDataPort>,
|
|
26
|
+
releaseContext: string | undefined = undefined
|
|
27
|
+
) {
|
|
28
|
+
return renderWithUi(
|
|
29
|
+
<TaxonomyPortProvider port={port}>
|
|
30
|
+
<SchemeContext.Provider value={scheme}>
|
|
31
|
+
<ReleaseContext.Provider value={releaseContext}>
|
|
32
|
+
<Hierarchy inputComponent={false} />
|
|
33
|
+
</ReleaseContext.Provider>
|
|
34
|
+
</SchemeContext.Provider>
|
|
35
|
+
</TaxonomyPortProvider>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('Hierarchy (trunk watch seam)', () => {
|
|
40
|
+
it('watches the trunk tree with the document id and perspective', () => {
|
|
41
|
+
const port = createFakeDataPort({loading: true})
|
|
42
|
+
renderHierarchy(port)
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText('Loading hierarchy…')).toBeInTheDocument()
|
|
45
|
+
expect(port.lastWatchParams).toEqual({
|
|
46
|
+
mode: 'trunk',
|
|
47
|
+
documentId: 'scheme-1',
|
|
48
|
+
perspective: undefined,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders the concepts delivered by the port', () => {
|
|
53
|
+
const tree: DocumentConcepts = {
|
|
54
|
+
topConcepts: [{id: 'concept-1', prefLabel: 'Animals'}],
|
|
55
|
+
concepts: [],
|
|
56
|
+
}
|
|
57
|
+
renderHierarchy(createFakeDataPort({tree}))
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole('button', {name: /Animals/})).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('routes a concept removal through the port as a remove plan', async () => {
|
|
63
|
+
const tree: DocumentConcepts = {
|
|
64
|
+
topConcepts: [{id: 'concept-1', prefLabel: 'Animals'}],
|
|
65
|
+
concepts: [],
|
|
66
|
+
}
|
|
67
|
+
const port = createFakeDataPort({tree})
|
|
68
|
+
renderHierarchy(port)
|
|
69
|
+
|
|
70
|
+
const removeAction = screen.getByLabelText('Remove this concept from this scheme')
|
|
71
|
+
await userEvent.setup().click(removeAction)
|
|
72
|
+
|
|
73
|
+
expect(port.appliedPlans).toHaveLength(1)
|
|
74
|
+
expect(port.appliedPlans[0]).toMatchObject({
|
|
75
|
+
kind: 'remove',
|
|
76
|
+
unsetPaths: ['topConcepts[_ref=="concept-1"]'],
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('optimistically removes the concept from the tree before the listener catches up', async () => {
|
|
81
|
+
// The fake's watch data stays static, standing in for the listener's refetch lag.
|
|
82
|
+
const tree: DocumentConcepts = {
|
|
83
|
+
topConcepts: [{id: 'concept-1', prefLabel: 'Animals'}],
|
|
84
|
+
concepts: [],
|
|
85
|
+
}
|
|
86
|
+
renderHierarchy(createFakeDataPort({tree}))
|
|
87
|
+
|
|
88
|
+
expect(screen.getByRole('button', {name: /Animals/})).toBeInTheDocument()
|
|
89
|
+
await userEvent.setup().click(screen.getByLabelText('Remove this concept from this scheme'))
|
|
90
|
+
expect(screen.queryByRole('button', {name: /Animals/})).not.toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -3,13 +3,13 @@ import type {DocumentId} from '@sanity/id-utils'
|
|
|
3
3
|
import {getPublishedId} from '@sanity/id-utils'
|
|
4
4
|
import {Flex, Spinner, Stack, Box, Text, Inline, Card, Button} from '@sanity/ui'
|
|
5
5
|
import {nanoid} from 'nanoid'
|
|
6
|
-
import {useCallback, useContext, useState} from 'react'
|
|
7
|
-
import {useListeningQuery} from 'sanity-plugin-utils'
|
|
6
|
+
import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
|
|
8
7
|
|
|
9
|
-
import {SchemeContext, TreeContext, ReleaseContext} from '../context'
|
|
8
|
+
import {OptimisticTreeContext, SchemeContext, TreeContext, ReleaseContext} from '../context'
|
|
9
|
+
import {collectConceptIds, pruneConcepts} from '../core/tree/pruneConcepts'
|
|
10
10
|
import {useCreateConcept} from '../hooks'
|
|
11
|
-
import {
|
|
12
|
-
import type {
|
|
11
|
+
import {useTaxonomyDataPort} from '../seams/TaxonomyPortContext'
|
|
12
|
+
import type {ConceptSchemeDocument, TreeViewProps} from '../types'
|
|
13
13
|
|
|
14
14
|
import {NewScheme} from './guides'
|
|
15
15
|
import {TreeStructure} from './TreeStructure'
|
|
@@ -29,8 +29,9 @@ export const Hierarchy = ({
|
|
|
29
29
|
}: TreeViewProps) => {
|
|
30
30
|
const document: ConceptSchemeDocument = useContext(SchemeContext) || ({} as ConceptSchemeDocument)
|
|
31
31
|
const documentId = getPublishedId(document.displayed?._id as DocumentId)
|
|
32
|
-
const releaseContext
|
|
32
|
+
const releaseContext = useContext(ReleaseContext)
|
|
33
33
|
|
|
34
|
+
const port = useTaxonomyDataPort()
|
|
34
35
|
const createConcept = useCreateConcept(document)
|
|
35
36
|
const createTopConcept = useCallback(() => {
|
|
36
37
|
createConcept('topConcept')
|
|
@@ -57,20 +58,50 @@ export const Hierarchy = ({
|
|
|
57
58
|
setGlobalVisibility({treeId: nanoid(6), treeVisibility: 'closed'})
|
|
58
59
|
}, [])
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
// Optimistic removal: prune a removed concept from the tree immediately, then
|
|
62
|
+
// let the live listener reconcile (see core/tree/pruneConcepts). markRemoved is
|
|
63
|
+
// reached by the deep remove actions through OptimisticTreeContext below.
|
|
64
|
+
const [removedIds, setRemovedIds] = useState<Set<string>>(() => new Set())
|
|
65
|
+
const markRemoved = useCallback((id: string) => {
|
|
66
|
+
setRemovedIds((prev) => {
|
|
67
|
+
if (prev.has(id)) return prev
|
|
68
|
+
const next = new Set(prev)
|
|
69
|
+
next.add(id)
|
|
70
|
+
return next
|
|
71
|
+
})
|
|
72
|
+
}, [])
|
|
73
|
+
const unmarkRemoved = useCallback((id: string) => {
|
|
74
|
+
setRemovedIds((prev) => {
|
|
75
|
+
if (!prev.has(id)) return prev
|
|
76
|
+
const next = new Set(prev)
|
|
77
|
+
next.delete(id)
|
|
78
|
+
return next
|
|
79
|
+
})
|
|
80
|
+
}, [])
|
|
81
|
+
const optimisticValue = useMemo(
|
|
82
|
+
() => ({markRemoved, unmarkRemoved}),
|
|
83
|
+
[markRemoved, unmarkRemoved],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const {data, loading, error} = port.useWatchTree({
|
|
87
|
+
mode: 'trunk',
|
|
88
|
+
documentId,
|
|
89
|
+
perspective: releaseContext,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Once fresh data no longer contains a removed id, stop tracking it so the
|
|
93
|
+
// optimistic set self-cleans and never prunes a later re-added concept.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setRemovedIds((prev) => {
|
|
96
|
+
if (prev.size === 0) return prev
|
|
97
|
+
const present = collectConceptIds(data)
|
|
98
|
+
const next = new Set([...prev].filter((id) => present.has(id)))
|
|
99
|
+
return next.size === prev.size ? prev : next
|
|
100
|
+
})
|
|
101
|
+
}, [data])
|
|
102
|
+
|
|
103
|
+
const prunedData = useMemo(() => pruneConcepts(data, removedIds), [data, removedIds])
|
|
104
|
+
|
|
74
105
|
if (loading) {
|
|
75
106
|
return (
|
|
76
107
|
<Box padding={4} paddingTop={6}>
|
|
@@ -85,7 +116,7 @@ export const Hierarchy = ({
|
|
|
85
116
|
} else if (error) {
|
|
86
117
|
console.error(error)
|
|
87
118
|
return <div>Error here</div>
|
|
88
|
-
} else if (!
|
|
119
|
+
} else if (!prunedData) {
|
|
89
120
|
return <NewScheme document={document} />
|
|
90
121
|
}
|
|
91
122
|
return (
|
|
@@ -96,10 +127,12 @@ export const Hierarchy = ({
|
|
|
96
127
|
<Card borderBottom paddingBottom={1} display={'flex'} flex={1}>
|
|
97
128
|
<Flex justify={'space-between'} flex={1}>
|
|
98
129
|
<Card>
|
|
99
|
-
{(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
{(prunedData.topConcepts?.filter(
|
|
131
|
+
(concept) => (concept?.childConcepts?.length ?? 0) > 0,
|
|
132
|
+
).length > 0 ||
|
|
133
|
+
prunedData.concepts?.filter(
|
|
134
|
+
(concept) => (concept?.childConcepts?.length ?? 0) > 0,
|
|
135
|
+
).length > 0) && (
|
|
103
136
|
<Inline space={1}>
|
|
104
137
|
<Button
|
|
105
138
|
text={'Collapse'}
|
|
@@ -139,11 +172,13 @@ export const Hierarchy = ({
|
|
|
139
172
|
</Flex>
|
|
140
173
|
</Card>
|
|
141
174
|
</Stack>
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
175
|
+
<OptimisticTreeContext.Provider value={optimisticValue}>
|
|
176
|
+
<TreeStructure
|
|
177
|
+
concepts={prunedData}
|
|
178
|
+
inputComponent={inputComponent}
|
|
179
|
+
selectConcept={selectConcept || (() => undefined)}
|
|
180
|
+
/>
|
|
181
|
+
</OptimisticTreeContext.Provider>
|
|
147
182
|
</>
|
|
148
183
|
</Box>
|
|
149
184
|
</TreeContext.Provider>
|
|
@@ -30,13 +30,13 @@ export const TopConcepts = ({
|
|
|
30
30
|
selectConcept,
|
|
31
31
|
}: TopConceptsProps) => {
|
|
32
32
|
const document: ConceptSchemeDocument = useContext(SchemeContext) || ({} as ConceptSchemeDocument)
|
|
33
|
-
const releaseContext
|
|
33
|
+
const releaseContext = useContext(ReleaseContext)
|
|
34
34
|
|
|
35
35
|
const createConcept = useCreateConcept(document)
|
|
36
36
|
const removeConcept = useRemoveConcept(document)
|
|
37
37
|
|
|
38
38
|
const [levelVisibility, setLevelVisibility] = useState(
|
|
39
|
-
treeVisibility === 'closed' && concept.hasMatchingDescendant ? 'open' : treeVisibility
|
|
39
|
+
treeVisibility === 'closed' && concept.hasMatchingDescendant ? 'open' : treeVisibility,
|
|
40
40
|
)
|
|
41
41
|
|
|
42
42
|
const handleToggle = useCallback(() => {
|
|
@@ -18,7 +18,7 @@ export const TreeStructure = ({
|
|
|
18
18
|
inputComponent,
|
|
19
19
|
selectConcept,
|
|
20
20
|
}: {
|
|
21
|
-
concepts: DocumentConcepts
|
|
21
|
+
concepts: DocumentConcepts | null
|
|
22
22
|
inputComponent: boolean
|
|
23
23
|
selectConcept: (conceptId: {_ref: string; _type: 'reference'; _originalId?: string}) => void
|
|
24
24
|
}) => {
|
|
@@ -32,7 +32,7 @@ export const TreeStructure = ({
|
|
|
32
32
|
return (
|
|
33
33
|
<Box paddingTop={4}>
|
|
34
34
|
<Stack space={3} style={{listStyle: 'none', paddingLeft: '0', marginBlockStart: '0'}}>
|
|
35
|
-
{concepts
|
|
35
|
+
{concepts?.topConcepts?.map((concept: TopConceptTerm) => (
|
|
36
36
|
<TopConcepts
|
|
37
37
|
key={`${concept?.id}+${treeId}`}
|
|
38
38
|
concept={concept}
|
|
@@ -41,7 +41,7 @@ export const TreeStructure = ({
|
|
|
41
41
|
selectConcept={selectConcept}
|
|
42
42
|
/>
|
|
43
43
|
))}
|
|
44
|
-
{concepts
|
|
44
|
+
{concepts?.concepts
|
|
45
45
|
?.filter((concept: ChildConceptTerm) => concept?.isOrphan)
|
|
46
46
|
.map((concept: ChildConceptTerm) => {
|
|
47
47
|
return (
|
|
@@ -0,0 +1,102 @@
|
|
|
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 {arrayFieldProps, fakeSchemeClient, filterFor} from '../../test/inputHarness'
|
|
9
|
+
import {renderWithUi} from '../../test/renderWithUi'
|
|
10
|
+
|
|
11
|
+
import {ArrayHierarchyInput} from './ArrayHierarchyInput'
|
|
12
|
+
|
|
13
|
+
// See ReferenceHierarchyInput.test.tsx for the harness rationale: stub the Studio
|
|
14
|
+
// hooks/chrome from `sanity`, drive the data seam through an injected FakeDataPort.
|
|
15
|
+
const mocks = vi.hoisted(() => ({
|
|
16
|
+
useClient: vi.fn(),
|
|
17
|
+
useFormValue: vi.fn(),
|
|
18
|
+
usePerspective: vi.fn(),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
vi.mock('sanity', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<typeof import('sanity')>()
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
useClient: mocks.useClient,
|
|
26
|
+
useFormValue: mocks.useFormValue,
|
|
27
|
+
usePerspective: mocks.usePerspective,
|
|
28
|
+
FormField: ({title, children}: {title?: string; children?: ReactNode}) => (
|
|
29
|
+
<div data-testid="form-field">
|
|
30
|
+
{title}
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function renderInput(props = arrayFieldProps(), port = createFakeDataPort()) {
|
|
38
|
+
return renderWithUi(
|
|
39
|
+
<TaxonomyPortProvider port={port}>
|
|
40
|
+
<ArrayHierarchyInput {...props} />
|
|
41
|
+
</TaxonomyPortProvider>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
mocks.useClient.mockReturnValue(fakeSchemeClient())
|
|
48
|
+
mocks.useFormValue.mockReturnValue('doc-1')
|
|
49
|
+
mocks.usePerspective.mockReturnValue({selectedPerspectiveName: undefined})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('ArrayHierarchyInput', () => {
|
|
53
|
+
it('warns when the field is configured without a filter', () => {
|
|
54
|
+
renderInput(arrayFieldProps({schemaType: {of: [{options: {}}]}}))
|
|
55
|
+
|
|
56
|
+
expect(screen.getByText(/must be used with an accompanying/i)).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('warns and falls back when the array has more than one schema type', () => {
|
|
60
|
+
renderInput(
|
|
61
|
+
arrayFieldProps({
|
|
62
|
+
schemaType: {of: [{options: {filter: filterFor({schemeId: 'scheme-x'})}}, {options: {}}]},
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText(/not supported for multi-schema arrays/i)).toBeInTheDocument()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('disables the browse button on the published perspective', async () => {
|
|
70
|
+
mocks.usePerspective.mockReturnValue({selectedPerspectiveName: 'published'})
|
|
71
|
+
renderInput()
|
|
72
|
+
|
|
73
|
+
expect(await screen.findByRole('button', {name: /browse taxonomy tree/i})).toBeDisabled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('suppresses the default input in browse-only mode, showing an empty preview', async () => {
|
|
77
|
+
renderInput(
|
|
78
|
+
arrayFieldProps({
|
|
79
|
+
schemaType: {of: [{options: {filter: filterFor({schemeId: 'scheme-x'}, {browseOnly: true})}}]},
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
expect(await screen.findByText('No items')).toBeInTheDocument()
|
|
84
|
+
expect(screen.queryByTestId('default-input')).not.toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('warns instead of adding a term already present in the field', async () => {
|
|
88
|
+
const port = createFakeDataPort({
|
|
89
|
+
tree: {topConcepts: [{id: 'concept-1', prefLabel: 'Biology', _originalId: 'concept-1'}], concepts: []},
|
|
90
|
+
})
|
|
91
|
+
renderInput(
|
|
92
|
+
arrayFieldProps({value: [{_key: 'k1', _ref: 'concept-1', _type: 'reference'}]}),
|
|
93
|
+
port
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const user = userEvent.setup()
|
|
97
|
+
await user.click(await screen.findByRole('button', {name: /browse taxonomy tree/i}))
|
|
98
|
+
await user.click(await screen.findByRole('button', {name: /Biology/}))
|
|
99
|
+
|
|
100
|
+
expect(await screen.findByText(/already included in this field/i)).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
})
|