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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
+
})
|