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
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "sanity-plugin-taxonomy-manager",
3
- "version": "4.7.2",
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": "./lib/index.esm.mjs",
28
- "require": "./lib/index.js",
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": "./lib/index.js",
34
- "module": "./lib/index.esm.js",
35
- "types": "./lib/index.d.ts",
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
36
35
  "files": [
37
- "lib",
38
- "sanity.json",
39
- "src",
40
- "v2-incompatible.js"
36
+ "dist",
37
+ "src"
41
38
  ],
42
39
  "scripts": {
43
- "prebuild": "npm run clean && plugin-kit verify-package --silent && pkg-utils",
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:run": "vitest run",
53
- "test:coverage": "vitest run --coverage",
54
- "test:watch": "vitest --watch"
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/incompatible-plugin": "^1.0.4",
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/plugin-kit": "^4.0.19",
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
- "@typescript-eslint/eslint-plugin": "^5.62.0",
78
- "@typescript-eslint/parser": "^5.62.0",
79
- "@vitejs/plugin-react": "^4.6.0",
80
- "@vitest/coverage-v8": "^3.2.4",
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
- "npm-run-all": "^4.1.5",
91
- "prettier": "^2.8.7",
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": "^4.10.1",
98
- "sanity-plugin-utils": "^1.7.0",
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
- "react": ">=18 <20 || ^19",
104
- "react-dom": ">=18 <20 || ^19",
105
- "sanity": "^4.0.0 || ^5.0.0-0"
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: string = useContext(ReleaseContext) as string
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 ? 'open' : treeVisibility || 'open'
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: string = useContext(ReleaseContext) as string
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 {trunkBuilder} from '../queries'
12
- import type {DocumentConcepts, ConceptSchemeDocument, TreeViewProps} from '../types'
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: string = useContext(ReleaseContext) as string
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
- const {data, loading, error} = useListeningQuery<DocumentConcepts>(
61
- {
62
- fetch: trunkBuilder(),
63
- listen: `*[_type == "skosConcept" || _type == "skosConceptScheme" ]`,
64
- },
65
- {
66
- params: {
67
- id: documentId,
68
- },
69
- options: {
70
- perspective: releaseContext === undefined ? 'drafts' : [releaseContext],
71
- },
72
- }
73
- ) as {data: DocumentConcepts; loading: boolean; error: Error | null}
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 (!data) {
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
- {(data.topConcepts?.filter((concept) => (concept?.childConcepts?.length ?? 0) > 0)
100
- .length > 0 ||
101
- data.concepts?.filter((concept) => (concept?.childConcepts?.length ?? 0) > 0)
102
- .length > 0) && (
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
- <TreeStructure
143
- concepts={data}
144
- inputComponent={inputComponent}
145
- selectConcept={selectConcept || (() => undefined)}
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: string = useContext(ReleaseContext) as string
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.topConcepts?.map((concept: TopConceptTerm) => (
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.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
+ })