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
@@ -0,0 +1,127 @@
1
+ import {getPublishedId, type DocumentId} from '@sanity/id-utils'
2
+
3
+ import type {ConceptRecommendation} from '../types'
4
+
5
+ /**
6
+ * #### Semantic Recommendations (pure core)
7
+ * The decision-bearing logic for the semantic term-recommendation seam: the GROQ
8
+ * query, the query-text assembly + validation, result mapping, and error
9
+ * messaging. The Studio adapter (`useSemanticRecommendations`) supplies the form
10
+ * values, runs the query, and owns React state; everything here is pure so it can
11
+ * be unit-tested without Studio or the network.
12
+ *
13
+ * Replaces the deprecated Embeddings Index API (`/embeddings-index/query/…`,
14
+ * `text::embedding()`) with dataset embeddings queried through
15
+ * `text::semanticSimilarity()` — no named index, scored in GROQ.
16
+ */
17
+
18
+ /**
19
+ * GROQ for the recommendations query. Scores the `skosConcept`s belonging to the
20
+ * scheme identified by `$schemeId` — those referenced by that scheme's `topConcepts`
21
+ * or `concepts` (concepts carry no scheme back-reference, so membership runs through
22
+ * the scheme) — by semantic similarity to `$searchQuery`, returning the top
23
+ * `$maxResults` as `{conceptId, score}`, most-relevant first. Scoping to the field's
24
+ * scheme keeps recommendations within the vocabulary the field draws from rather than
25
+ * the whole dataset. `text::semanticSimilarity()` is only valid inside `score()`, and
26
+ * the dataset must have embeddings enabled or the query errors (surfaced via
27
+ * {@link recommendationsErrorMessage}).
28
+ *
29
+ * Scoping is scheme-level: a `branchFilter` field still scores the whole scheme, not
30
+ * just the displayed branch. Branch-level scoping is tracked in #93.
31
+ */
32
+ export const recommendationsQuery = (): string =>
33
+ `*[_type == "skosConcept" && _id in *[_type == "skosConceptScheme" && schemeId == $schemeId][0]{
34
+ "ids": coalesce(topConcepts[]._ref, []) + coalesce(concepts[]._ref, [])
35
+ }.ids] | score(text::semanticSimilarity($searchQuery)) | order(_score desc) [0...$maxResults] {
36
+ "conceptId": _id,
37
+ "score": _score
38
+ }`
39
+
40
+ /** A document field name paired with its current value, read from the edited document. */
41
+ export interface RecommendationField {
42
+ name: string
43
+ value: unknown
44
+ }
45
+
46
+ /**
47
+ * Concatenate the referenced fields' values into the semantic-search query text.
48
+ * Every field must hold a non-empty string; otherwise this throws with a message
49
+ * naming the empty field(s), which the adapter surfaces as `recsError` rather than
50
+ * searching against partial input. The messages are preserved verbatim from the
51
+ * pre-migration embeddings hook.
52
+ */
53
+ export function assembleQueryText(fields: RecommendationField[]): string {
54
+ const values: string[] = []
55
+ const emptyFields: string[] = []
56
+ for (const {name, value} of fields) {
57
+ if (typeof value === 'string' && value.trim() !== '') {
58
+ values.push(value)
59
+ } else {
60
+ emptyFields.push(name)
61
+ }
62
+ }
63
+ if (emptyFields.length === 1) {
64
+ throw new Error(`Please fill out the ${emptyFields[0]} field to enable match scores.`)
65
+ }
66
+ if (emptyFields.length > 1) {
67
+ throw new Error(
68
+ `The following fields must be filled out to enable match scores: ${emptyFields.join(', ')}`
69
+ )
70
+ }
71
+ return values.join(' ')
72
+ }
73
+
74
+ /** A raw row from {@link recommendationsQuery}, before id normalization. */
75
+ export interface RecommendationRow {
76
+ conceptId: string
77
+ score: number
78
+ }
79
+
80
+ /**
81
+ * Map raw query rows to {@link ConceptRecommendation}s, normalizing each concept id
82
+ * to its published form so they line up with `annotateRanks`, which keys the tree by
83
+ * published id. Rows missing a string id or numeric score are dropped.
84
+ */
85
+ export function toConceptRecommendations(
86
+ rows: readonly RecommendationRow[] | null | undefined
87
+ ): ConceptRecommendation[] {
88
+ if (!rows) return []
89
+ const recs: ConceptRecommendation[] = []
90
+ for (const row of rows) {
91
+ if (!row || typeof row.conceptId !== 'string' || row.conceptId === '') continue
92
+ if (typeof row.score !== 'number') continue
93
+ recs.push({conceptId: getPublishedId(row.conceptId as DocumentId), score: row.score})
94
+ }
95
+ return recs
96
+ }
97
+
98
+ /**
99
+ * The set of published concept ids that are semantic recommendations. The raw
100
+ * `text::semanticSimilarity()` score is opaque and unitless (meaningful only for
101
+ * relative ordering within one query), so we keep only set membership — a concept
102
+ * is either recommended or it isn't — and let the query's `maxResults` bound the
103
+ * size. De-duplicated by construction.
104
+ */
105
+ export function recommendedConceptIds(recs: readonly ConceptRecommendation[]): Set<string> {
106
+ return new Set(recs.map((rec) => rec.conceptId))
107
+ }
108
+
109
+ /** Friendly message shown when a dataset has no embeddings enabled. */
110
+ const EMBEDDINGS_DISABLED_MESSAGE = "Semantic recommendations aren't enabled for this dataset."
111
+ /** Fallback for any other recommendations failure. */
112
+ const GENERIC_RECS_ERROR_MESSAGE = 'Unable to load semantic recommendations.'
113
+
114
+ /**
115
+ * Map a failed recommendations fetch to a friendly `recsError` message. A dataset
116
+ * without embeddings is the expected failure — `text::semanticSimilarity()` errors
117
+ * there — so an embeddings-related error becomes a clear setup hint; anything else
118
+ * falls back to a generic message. Either way the tree still renders, only without
119
+ * scores.
120
+ *
121
+ * The embeddings-disabled detection matches on the error text; confirm it against
122
+ * the live API error in a `pnpm dev` pass and widen the match if the wording differs.
123
+ */
124
+ export function recommendationsErrorMessage(error: unknown): string {
125
+ const text = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
126
+ return /embedding/i.test(text) ? EMBEDDINGS_DISABLED_MESSAGE : GENERIC_RECS_ERROR_MESSAGE
127
+ }
@@ -0,0 +1,43 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import type {ChildConceptTerm} from '../../types'
4
+
5
+ import {annotateRecommendations} from './annotateRecommendations'
6
+
7
+ function node(id: string, childConcepts?: ChildConceptTerm[]): ChildConceptTerm {
8
+ return {id, prefLabel: id, ...(childConcepts ? {childConcepts} : {})}
9
+ }
10
+
11
+ describe('annotateRecommendations', () => {
12
+ it('marks a matching node as recommended', () => {
13
+ const result = annotateRecommendations(node('c-1'), new Set(['c-1']))
14
+ expect(result.recommended).toBe(true)
15
+ })
16
+
17
+ it('normalizes the node id (strips drafts. prefix) before lookup', () => {
18
+ const result = annotateRecommendations(node('drafts.c-1'), new Set(['c-1']))
19
+ expect(result.recommended).toBe(true)
20
+ })
21
+
22
+ it('leaves recommended and hasMatchingDescendant unset when nothing matches', () => {
23
+ const result = annotateRecommendations(node('c-1'), new Set())
24
+ expect(result.recommended).toBeUndefined()
25
+ expect(result.hasMatchingDescendant).toBeUndefined()
26
+ })
27
+
28
+ it('flags hasMatchingDescendant up the ancestor chain when a descendant is recommended', () => {
29
+ const tree = node('root', [node('mid', [node('leaf')])])
30
+ const result = annotateRecommendations(tree, new Set(['leaf']))
31
+
32
+ expect(result.recommended).toBeUndefined()
33
+ expect(result.hasMatchingDescendant).toBe(true)
34
+ expect(result.childConcepts?.[0]?.hasMatchingDescendant).toBe(true)
35
+ expect(result.childConcepts?.[0]?.childConcepts?.[0]?.recommended).toBe(true)
36
+ })
37
+
38
+ it('does not mutate the input node', () => {
39
+ const input = node('c-1', [node('c-2')])
40
+ annotateRecommendations(input, new Set(['c-1']))
41
+ expect(input).toEqual(node('c-1', [node('c-2')]))
42
+ })
43
+ })
@@ -0,0 +1,30 @@
1
+ import {getPublishedId, type DocumentId} from '@sanity/id-utils'
2
+
3
+ import type {ChildConceptTerm} from '../../types'
4
+
5
+ /**
6
+ * Recursively annotate hierarchy nodes: mark a node `recommended` when its published
7
+ * id is in the recommendation set, and set `hasMatchingDescendant` when any descendant
8
+ * is recommended. Children are processed first so the flag propagates up the ancestor
9
+ * chain. Pure: returns new nodes and never mutates the input.
10
+ */
11
+ export function annotateRecommendations<T extends ChildConceptTerm>(
12
+ node: T,
13
+ recommendedIds: Set<string>
14
+ ): T {
15
+ const recommended = recommendedIds.has(getPublishedId(node.id as DocumentId))
16
+
17
+ const annotatedChildren = node.childConcepts?.map((child) =>
18
+ annotateRecommendations(child, recommendedIds)
19
+ )
20
+
21
+ const hasMatchingDescendant =
22
+ annotatedChildren?.some((child) => child.recommended || child.hasMatchingDescendant) ?? false
23
+
24
+ return {
25
+ ...node,
26
+ ...(recommended ? {recommended: true} : {}),
27
+ ...(hasMatchingDescendant ? {hasMatchingDescendant: true} : {}),
28
+ ...(annotatedChildren ? {childConcepts: annotatedChildren} : {}),
29
+ } as T
30
+ }
@@ -0,0 +1,77 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import type {DocumentConcepts} from '../../types'
4
+
5
+ import {collectConceptIds, pruneConcepts} from './pruneConcepts'
6
+
7
+ const tree = (): DocumentConcepts => ({
8
+ topConcepts: [
9
+ {
10
+ id: 'top-1',
11
+ prefLabel: 'Top 1',
12
+ childConcepts: [
13
+ {id: 'child-1', prefLabel: 'Child 1', level: 1},
14
+ {
15
+ id: 'child-2',
16
+ prefLabel: 'Child 2',
17
+ level: 1,
18
+ childConcepts: [{id: 'grandchild-1', prefLabel: 'Grandchild 1', level: 2}],
19
+ },
20
+ ],
21
+ },
22
+ {id: 'top-2', prefLabel: 'Top 2'},
23
+ ],
24
+ concepts: [{id: 'orphan-1', prefLabel: 'Orphan 1', isOrphan: true}],
25
+ })
26
+
27
+ describe('pruneConcepts', () => {
28
+ it('returns the same reference when nothing is removed', () => {
29
+ const data = tree()
30
+ expect(pruneConcepts(data, new Set())).toBe(data)
31
+ })
32
+
33
+ it('passes null through', () => {
34
+ expect(pruneConcepts(null, new Set(['top-1']))).toBeNull()
35
+ })
36
+
37
+ it('removes a top concept (and its subtree) by id', () => {
38
+ const result = pruneConcepts(tree(), new Set(['top-1']))
39
+ expect(result?.topConcepts.map((c) => c.id)).toEqual(['top-2'])
40
+ expect(result?.concepts.map((c) => c.id)).toEqual(['orphan-1'])
41
+ })
42
+
43
+ it('removes a nested child without touching its siblings', () => {
44
+ const result = pruneConcepts(tree(), new Set(['child-1']))
45
+ expect(result?.topConcepts[0].childConcepts?.map((c) => c.id)).toEqual(['child-2'])
46
+ })
47
+
48
+ it('removes a deeply nested grandchild', () => {
49
+ const result = pruneConcepts(tree(), new Set(['grandchild-1']))
50
+ const child2 = result?.topConcepts[0].childConcepts?.find((c) => c.id === 'child-2')
51
+ expect(child2?.childConcepts).toEqual([])
52
+ })
53
+
54
+ it('removes an orphan concept', () => {
55
+ const result = pruneConcepts(tree(), new Set(['orphan-1']))
56
+ expect(result?.concepts).toEqual([])
57
+ })
58
+
59
+ it('does not mutate the input', () => {
60
+ const data = tree()
61
+ pruneConcepts(data, new Set(['child-1', 'orphan-1']))
62
+ expect(data.topConcepts[0].childConcepts?.map((c) => c.id)).toEqual(['child-1', 'child-2'])
63
+ expect(data.concepts.map((c) => c.id)).toEqual(['orphan-1'])
64
+ })
65
+ })
66
+
67
+ describe('collectConceptIds', () => {
68
+ it('collects every id across top, orphan, and nested children', () => {
69
+ expect(collectConceptIds(tree())).toEqual(
70
+ new Set(['top-1', 'child-1', 'child-2', 'grandchild-1', 'top-2', 'orphan-1'])
71
+ )
72
+ })
73
+
74
+ it('returns an empty set for null', () => {
75
+ expect(collectConceptIds(null)).toEqual(new Set())
76
+ })
77
+ })
@@ -0,0 +1,72 @@
1
+ import type {ChildConceptTerm, DocumentConcepts} from '../../types'
2
+
3
+ type ConceptNodeList = readonly {id: string; childConcepts?: ChildConceptTerm[]}[] | undefined
4
+
5
+ /**
6
+ * Recursively remove every concept whose `id` is in `removedIds` from a concept
7
+ * tree — top concepts, orphan/concepts, and all nested `childConcepts`. Returns
8
+ * a new tree without mutating the input; passes `null` and an empty removal set
9
+ * through unchanged (same reference).
10
+ *
11
+ * Used by the optimistic-removal layer in `Hierarchy`: a concept unset from a
12
+ * scheme disappears tree-wide (the trunk query gates every node on scheme
13
+ * membership), so pruning by id mirrors what the live listener will eventually
14
+ * return — letting the tree update instantly instead of waiting on the refetch.
15
+ */
16
+ export function pruneConcepts(
17
+ data: DocumentConcepts | null,
18
+ removedIds: ReadonlySet<string>
19
+ ): DocumentConcepts | null {
20
+ if (!data || removedIds.size === 0) return data
21
+ return {
22
+ ...data,
23
+ topConcepts: (data.topConcepts ?? [])
24
+ .filter((concept) => !removedIds.has(concept.id))
25
+ .map((concept) =>
26
+ concept.childConcepts
27
+ ? {...concept, childConcepts: pruneChildren(concept.childConcepts, removedIds)}
28
+ : concept
29
+ ),
30
+ concepts: (data.concepts ?? [])
31
+ .filter((concept) => !removedIds.has(concept.id))
32
+ .map((concept) =>
33
+ concept.childConcepts
34
+ ? {...concept, childConcepts: pruneChildren(concept.childConcepts, removedIds)}
35
+ : concept
36
+ ),
37
+ }
38
+ }
39
+
40
+ function pruneChildren(
41
+ children: ChildConceptTerm[],
42
+ removedIds: ReadonlySet<string>
43
+ ): ChildConceptTerm[] {
44
+ return children
45
+ .filter((concept) => !removedIds.has(concept.id))
46
+ .map((concept) =>
47
+ concept.childConcepts
48
+ ? {...concept, childConcepts: pruneChildren(concept.childConcepts, removedIds)}
49
+ : concept
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Collect every concept `id` present anywhere in the tree (top, orphan, and all
55
+ * nested children). Used to reconcile the optimistic removal set against fresh
56
+ * listener data — once an id is gone from the data, it can be forgotten.
57
+ */
58
+ export function collectConceptIds(data: DocumentConcepts | null): Set<string> {
59
+ const ids = new Set<string>()
60
+ const walk = (concepts: ConceptNodeList): void => {
61
+ if (!concepts) return
62
+ for (const concept of concepts) {
63
+ ids.add(concept.id)
64
+ walk(concept.childConcepts)
65
+ }
66
+ }
67
+ if (data) {
68
+ walk(data.topConcepts)
69
+ walk(data.concepts)
70
+ }
71
+ return ids
72
+ }
@@ -0,0 +1,42 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {conceptFilter, prefLabelUniquenessResult} from './validation'
4
+
5
+ describe('conceptFilter', () => {
6
+ it('excludes self and already-referenced broader/related concepts', () => {
7
+ const result = conceptFilter({
8
+ document: {_id: 'concept-1', broader: [{_ref: 'b-1'}, {_ref: 'b-2'}], related: [{_ref: 'r-1'}]} as never,
9
+ })
10
+ expect(result.filter).toBe('!(_id in $broader || _id in $related || _id == $self)')
11
+ expect(result.params).toEqual({self: 'concept-1', broader: ['b-1', 'b-2'], related: ['r-1']})
12
+ })
13
+
14
+ it('defaults broader/related to empty arrays when absent', () => {
15
+ const result = conceptFilter({document: {_id: 'concept-1'} as never})
16
+ expect(result.params.broader).toEqual([])
17
+ expect(result.params.related).toEqual([])
18
+ })
19
+
20
+ it('normalizes self to the published id (strips the drafts. prefix)', () => {
21
+ const result = conceptFilter({document: {_id: 'drafts.concept-1'} as never})
22
+ expect(result.params.self).toBe('concept-1')
23
+ })
24
+ })
25
+
26
+ describe('prefLabelUniquenessResult', () => {
27
+ it('returns the error when a different published concept already uses the label', () => {
28
+ expect(prefLabelUniquenessResult('other-concept', 'concept-1')).toBe(
29
+ 'Preferred Label must be unique.',
30
+ )
31
+ })
32
+
33
+ it('passes when the existing match is the current document (draft or published)', () => {
34
+ expect(prefLabelUniquenessResult('concept-1', 'drafts.concept-1')).toBe(true)
35
+ expect(prefLabelUniquenessResult('concept-1', 'concept-1')).toBe(true)
36
+ })
37
+
38
+ it('passes when no existing concept uses the label', () => {
39
+ expect(prefLabelUniquenessResult(null, 'concept-1')).toBe(true)
40
+ expect(prefLabelUniquenessResult(undefined, 'concept-1')).toBe(true)
41
+ })
42
+ })
@@ -0,0 +1,42 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+ import type {SanityDocument} from 'sanity'
3
+
4
+ /**
5
+ * #### Concept reference filter
6
+ * Reference-field `options.filter` for broader/related concept fields: excludes
7
+ * the concept itself and any concepts already chosen as broader/related, keeping
8
+ * the two relationship sets mutually exclusive. Pure — the host calls it with
9
+ * the current document.
10
+ */
11
+ export function conceptFilter({document}: {document: SanityDocument}) {
12
+ const publishedId = getPublishedId(DocumentId(document._id))
13
+ return {
14
+ filter: '!(_id in $broader || _id in $related || _id == $self)',
15
+ params: {
16
+ self: publishedId,
17
+ broader: Array.isArray(document?.broader)
18
+ ? document.broader.map(({_ref}: {_ref: string}) => _ref)
19
+ : [],
20
+ related: Array.isArray(document?.related)
21
+ ? document.related.map(({_ref}: {_ref: string}) => _ref)
22
+ : [],
23
+ },
24
+ }
25
+ }
26
+
27
+ /**
28
+ * #### Preferred-label uniqueness result
29
+ * Given the id of an existing published concept that already uses a candidate
30
+ * prefLabel (or null/undefined if none), decide the validation result. Returns
31
+ * the error message when the match is a *different* document, otherwise `true`.
32
+ * The fetch that finds the existing concept stays in the schema (impure).
33
+ */
34
+ export function prefLabelUniquenessResult(
35
+ existingConceptId: string | null | undefined,
36
+ currentDocumentId: string | undefined,
37
+ ): string | true {
38
+ if (existingConceptId && !currentDocumentId?.includes(existingConceptId)) {
39
+ return 'Preferred Label must be unique.'
40
+ }
41
+ return true
42
+ }
@@ -0,0 +1,22 @@
1
+ import {defineField} from 'sanity'
2
+
3
+ import {RdfUri} from '../components/inputs'
4
+
5
+ /**
6
+ * #### Base Universal Resource Identifier object for Sanity Taxonomy Manager
7
+ * Used for Concept and Concept Scheme URI fields
8
+ */
9
+ export default [
10
+ defineField({
11
+ name: 'baseIri',
12
+ title: 'Base URI',
13
+ type: 'url',
14
+ validation: (Rule) =>
15
+ Rule.required().error(`Please supply a base URI in the format 'http://example.com/'`),
16
+ description:
17
+ 'The root URI (Uniform Resource Identifier) used to create unique concept identifiers.',
18
+ components: {
19
+ input: RdfUri as any,
20
+ },
21
+ }),
22
+ ]
@@ -0,0 +1,48 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {branchFilter} from './branchFilter'
4
+
5
+ type FilterProps = Parameters<ReturnType<typeof branchFilter>>[0]
6
+
7
+ function stubProps(result: {concepts: string[]}) {
8
+ const fetch = vi.fn().mockResolvedValue(result)
9
+ const getClient = vi.fn(() => ({fetch}))
10
+ return {props: {getClient} as unknown as FilterProps, fetch, getClient}
11
+ }
12
+
13
+ describe('branchFilter', () => {
14
+ it('throws synchronously when schemeId is missing or not a string', () => {
15
+ expect(() => branchFilter({branchId: 'd4e5f6'} as never)).toThrow(
16
+ 'Invalid or missing schemeId: scheme Id must be a string',
17
+ )
18
+ })
19
+
20
+ it('throws synchronously when branchId is missing or not a string', () => {
21
+ expect(() => branchFilter({schemeId: 'abc123'} as never)).toThrow(
22
+ 'Invalid or missing branchId: branch Id must be a string',
23
+ )
24
+ })
25
+
26
+ it('fetches with apiVersion 2023-01-01 and returns the filter, params, and options', async () => {
27
+ const {props, fetch, getClient} = stubProps({concepts: ['concept-x', 'concept-y']})
28
+
29
+ const result = await branchFilter({
30
+ schemeId: 'abc123',
31
+ branchId: 'd4e5f6',
32
+ expanded: true,
33
+ browseOnly: true,
34
+ })(props)
35
+
36
+ expect(getClient).toHaveBeenCalledWith({apiVersion: '2023-01-01'})
37
+ expect(result.filter).toMatchInlineSnapshot(`"_id in $concepts"`)
38
+ expect(result.params).toEqual({
39
+ concepts: ['concept-x', 'concept-y'],
40
+ schemeId: 'abc123',
41
+ branchId: 'd4e5f6',
42
+ })
43
+ expect(result.expanded).toBe(true)
44
+ expect(result.browseOnly).toBe(true)
45
+ // schemeId and branchId are passed as GROQ params (not interpolated)
46
+ expect(fetch.mock.calls[0]?.[1]).toEqual({schemeId: 'abc123', branchId: 'd4e5f6'})
47
+ })
48
+ })
@@ -1,5 +1,12 @@
1
1
  import type {useClient} from 'sanity'
2
2
 
3
+ import {
4
+ assertBranchId,
5
+ assertSchemeId,
6
+ buildBranchFilterResult,
7
+ type BranchFilterResult,
8
+ } from '../core/filters'
9
+
3
10
  type BranchOptions = {
4
11
  schemeId: string
5
12
  branchId: string
@@ -7,17 +14,6 @@ type BranchOptions = {
7
14
  browseOnly?: boolean
8
15
  }
9
16
 
10
- type BranchFilterResult = {
11
- filter: string
12
- params: {
13
- schemeId: string
14
- branchId: string
15
- concepts: string[]
16
- }
17
- expanded?: boolean
18
- browseOnly?: boolean
19
- }
20
-
21
17
  /**
22
18
  * #### Reference Field Scheme & Branch Filter
23
19
  * A pluggable Function for Filtering to a Top Concept Branch within a SKOS Concept Scheme
@@ -56,21 +52,15 @@ type BranchFilterResult = {
56
52
  * ```
57
53
  */
58
54
  export const branchFilter = (
59
- options: BranchOptions
55
+ options: BranchOptions,
60
56
  ): (({
61
57
  getClient,
62
58
  }: {
63
59
  getClient: (clientOptions: {apiVersion: string}) => ReturnType<typeof useClient>
64
60
  }) => Promise<BranchFilterResult>) => {
65
61
  const {schemeId, branchId = null} = options || {}
66
-
67
- if (!schemeId || typeof schemeId !== 'string') {
68
- throw new Error('Invalid or missing schemeId: scheme Id must be a string')
69
- }
70
-
71
- if (!branchId || typeof branchId !== 'string') {
72
- throw new Error('Invalid or missing branchId: branch Id must be a string')
73
- }
62
+ assertSchemeId(schemeId)
63
+ assertBranchId(branchId)
74
64
 
75
65
  return async (props) => {
76
66
  const client = props?.getClient({apiVersion: '2023-01-01'})
@@ -79,7 +69,7 @@ export const branchFilter = (
79
69
  }
80
70
 
81
71
  // Fetch concepts for the given schemeId and branchId
82
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unnecessary-type-assertion
72
+
83
73
  const {concepts} = (await client.fetch(
84
74
  `{
85
75
  "concepts": *[
@@ -91,14 +81,15 @@ export const branchFilter = (
91
81
  || $branchId in broader[]->broader[]->broader[]->broader[]->broader[]->conceptId)
92
82
  ]._id
93
83
  }`,
94
- {schemeId, branchId}
84
+ {schemeId, branchId},
95
85
  )) as {concepts: string[]}
96
86
  // schemeId is included in params for use by the ArrayHierarchyInput component
97
- return {
98
- filter: `_id in $concepts`,
99
- params: {concepts, schemeId, branchId},
87
+ return buildBranchFilterResult({
88
+ schemeId,
89
+ branchId,
90
+ concepts,
100
91
  expanded: options?.expanded,
101
92
  browseOnly: options?.browseOnly,
102
- }
93
+ })
103
94
  }
104
95
  }
@@ -0,0 +1,51 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {schemeFilter} from './schemeFilter'
4
+
5
+ type FilterProps = Parameters<ReturnType<typeof schemeFilter>>[0]
6
+
7
+ function stubProps(result: {concepts: string[]; topConcepts: string[]}) {
8
+ const fetch = vi.fn().mockResolvedValue(result)
9
+ const getClient = vi.fn(() => ({fetch}))
10
+ return {props: {getClient} as unknown as FilterProps, fetch, getClient}
11
+ }
12
+
13
+ describe('schemeFilter', () => {
14
+ it('throws synchronously when schemeId is missing or not a string', () => {
15
+ expect(() => schemeFilter({} as never)).toThrow(
16
+ 'Invalid or missing schemeId: scheme Id must be a string',
17
+ )
18
+ expect(() => schemeFilter({schemeId: 123 as never})).toThrow('Invalid or missing schemeId')
19
+ })
20
+
21
+ it('fetches with apiVersion 2025-02-19 and returns the filter, params, and options', async () => {
22
+ const {props, fetch, getClient} = stubProps({
23
+ concepts: ['concept-a', 'concept-b'],
24
+ topConcepts: ['top-1'],
25
+ })
26
+
27
+ const result = await schemeFilter({schemeId: 'abc123', expanded: true, browseOnly: true})(props)
28
+
29
+ expect(getClient).toHaveBeenCalledWith({apiVersion: '2025-02-19'})
30
+ expect(result.filter).toMatchInlineSnapshot(`
31
+ "(_id in $concepts
32
+ || _id in $topConcepts)"
33
+ `)
34
+ expect(result.params).toEqual({
35
+ concepts: ['concept-a', 'concept-b'],
36
+ topConcepts: ['top-1'],
37
+ schemeId: 'abc123',
38
+ })
39
+ expect(result.expanded).toBe(true)
40
+ expect(result.browseOnly).toBe(true)
41
+ // schemeId is string-interpolated into the fetch query (not passed as a param)
42
+ expect(fetch.mock.calls[0]?.[0]).toContain('schemeId == "abc123"')
43
+ })
44
+
45
+ it('passes expanded/browseOnly through as undefined when omitted', async () => {
46
+ const {props} = stubProps({concepts: [], topConcepts: []})
47
+ const result = await schemeFilter({schemeId: 'abc123'})(props)
48
+ expect(result.expanded).toBeUndefined()
49
+ expect(result.browseOnly).toBeUndefined()
50
+ })
51
+ })