@sanity/context 0.0.3 → 0.0.5
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/dist/studio.d.ts +19 -0
- package/dist/studio.js +291 -67
- package/dist/studio.js.map +1 -1
- package/package.json +18 -4
- package/scripts/install-skill.mjs +121 -0
- package/scripts/uninstall-skill.mjs +111 -0
- package/skills/SKILL.md +73 -0
- package/src/studio/context-plugin/{AgentDocumentInput.tsx → AgentContextDocumentInput.tsx} +2 -2
- package/src/studio/context-plugin/agentContextSchema.ts +15 -17
- package/src/studio/context-plugin/groq-filter-input/GroqFilterInput.tsx +369 -0
- package/src/studio/context-plugin/groq-filter-input/groqUtils.test.ts +69 -0
- package/src/studio/context-plugin/groq-filter-input/groqUtils.ts +48 -0
- package/src/studio/context-plugin/groq-filter-input/useComposedRefs.ts +17 -0
- package/src/studio/context-plugin/plugin.tsx +0 -34
- package/src/studio/index.ts +5 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Preuninstall script for @sanity/context
|
|
4
|
+
* Removes skill files from consuming project's AI assistant skill directories
|
|
5
|
+
*
|
|
6
|
+
* Fails silently to never break npm uninstall
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {existsSync, readdirSync, rmSync} from 'node:fs'
|
|
10
|
+
import {dirname, join, resolve} from 'node:path'
|
|
11
|
+
import {fileURLToPath} from 'node:url'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const SKILL_FOLDER_NAME = 'sanity-context-mcp'
|
|
15
|
+
|
|
16
|
+
const SKILL_DIRS = ['.claude/skills', '.cursor/skills', '.windsurf/skills']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Find the consuming project's root directory
|
|
20
|
+
*/
|
|
21
|
+
function findProjectRoot() {
|
|
22
|
+
let current = resolve(__dirname, '..')
|
|
23
|
+
|
|
24
|
+
while (current !== dirname(current)) {
|
|
25
|
+
current = dirname(current)
|
|
26
|
+
|
|
27
|
+
if (current.includes('node_modules')) {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (existsSync(join(current, 'package.json'))) {
|
|
32
|
+
return current
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if we're running in development mode (not as a dependency)
|
|
41
|
+
*/
|
|
42
|
+
function isDevMode() {
|
|
43
|
+
if (!__dirname.includes('node_modules')) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const initCwd = process.env.INIT_CWD
|
|
48
|
+
const packageDir = resolve(__dirname, '..')
|
|
49
|
+
|
|
50
|
+
if (initCwd && resolve(initCwd) === packageDir) {
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove directory if empty
|
|
59
|
+
*/
|
|
60
|
+
function removeIfEmpty(dirPath) {
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(dirPath)) {
|
|
63
|
+
const contents = readdirSync(dirPath)
|
|
64
|
+
if (contents.length === 0) {
|
|
65
|
+
rmSync(dirPath, {recursive: true})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore errors
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Main uninstall logic
|
|
75
|
+
*/
|
|
76
|
+
function main() {
|
|
77
|
+
try {
|
|
78
|
+
if (isDevMode()) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const projectRoot = findProjectRoot()
|
|
83
|
+
if (!projectRoot) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const skillDir of SKILL_DIRS) {
|
|
88
|
+
const targetDir = join(projectRoot, skillDir, SKILL_FOLDER_NAME)
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(targetDir)) {
|
|
92
|
+
// Remove the entire skill folder
|
|
93
|
+
rmSync(targetDir, {recursive: true, force: true})
|
|
94
|
+
|
|
95
|
+
// Clean up empty parent directories
|
|
96
|
+
const parentDir = join(projectRoot, skillDir)
|
|
97
|
+
removeIfEmpty(parentDir)
|
|
98
|
+
|
|
99
|
+
const grandparentDir = dirname(parentDir)
|
|
100
|
+
removeIfEmpty(grandparentDir)
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Fail silently - never break npm uninstall
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main()
|
package/skills/SKILL.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sanity-context-mcp
|
|
3
|
+
description: Install and configure the Sanity Context MCP plugin in a Sanity Studio and create AgentContext documents.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Sanity Context MCP Setup
|
|
7
|
+
|
|
8
|
+
Help developers install the Context MCP plugin and configure it in their Sanity Studio.
|
|
9
|
+
|
|
10
|
+
## What is Context MCP?
|
|
11
|
+
|
|
12
|
+
An MCP server that gives AI agents structured access to Sanity content. Preserves schema structure instead of flattening to embeddings — agents can filter by real field values, follow references, and combine structural queries with semantic search.
|
|
13
|
+
|
|
14
|
+
## Setup tasks
|
|
15
|
+
|
|
16
|
+
### 1. Install the Studio plugin
|
|
17
|
+
|
|
18
|
+
Install `@sanity/context` and add it to your Sanity Studio configuration:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// sanity.config.ts
|
|
22
|
+
import {contextPlugin} from '@sanity/context/studio'
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
// ...
|
|
26
|
+
plugins: [contextPlugin()],
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This adds the `sanity.agentContext` document type to the Studio.
|
|
31
|
+
|
|
32
|
+
### 2. Create an AgentContext document
|
|
33
|
+
|
|
34
|
+
Create a `sanity.agentContext` document in the Studio with:
|
|
35
|
+
|
|
36
|
+
- A `name` for display
|
|
37
|
+
- A `slug` (auto-generated from name) that identifies the context
|
|
38
|
+
- A `groqFilter` to scope what content the agent can access
|
|
39
|
+
|
|
40
|
+
The slug becomes the MCP URL: `https://context-mcp.sanity.io/mcp/:projectId/:dataset/:slug`
|
|
41
|
+
|
|
42
|
+
## Available MCP tools
|
|
43
|
+
|
|
44
|
+
| Tool | Purpose |
|
|
45
|
+
| ----------------- | ------------------------------------------------------ |
|
|
46
|
+
| `initial_context` | Get compressed schema overview (types, fields, counts) |
|
|
47
|
+
| `groq_query` | Execute GROQ queries with optional semantic search |
|
|
48
|
+
| `schema_explorer` | Get detailed schema for a specific type |
|
|
49
|
+
|
|
50
|
+
## GROQ with semantic search
|
|
51
|
+
|
|
52
|
+
Context MCP supports `text::embedding()` for semantic ranking inside `score()`:
|
|
53
|
+
|
|
54
|
+
- **Basic filtering** — Standard GROQ filters on type, fields, references
|
|
55
|
+
- **Semantic ranking** — Use `score(text::embedding("search terms"))` to rank by meaning
|
|
56
|
+
- **Hybrid search** — Combine structural filters with `score(text::embedding(...))` for hybrid queries
|
|
57
|
+
|
|
58
|
+
Always use `order(_score desc)` when using `score()` to get best matches first.
|
|
59
|
+
|
|
60
|
+
Example hybrid query:
|
|
61
|
+
|
|
62
|
+
```groq
|
|
63
|
+
*[_type == "product" && category == "sweaters" && price < 100]
|
|
64
|
+
| score(text::embedding("cozy warm comfortable"))
|
|
65
|
+
| order(_score desc)
|
|
66
|
+
{ _id, title, price }[0...10]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Guidelines
|
|
70
|
+
|
|
71
|
+
- Create AgentContext documents before connecting
|
|
72
|
+
- Start with `initial_context` to understand available content types
|
|
73
|
+
- Combine structural filters with semantic search for best results
|
|
@@ -2,14 +2,14 @@ import {CopyIcon} from '@sanity/icons'
|
|
|
2
2
|
import {Box, Button, Card, Flex, Stack, Text, useToast} from '@sanity/ui'
|
|
3
3
|
import {getValueAtPath, type InputProps, useDataset, useProjectId} from 'sanity'
|
|
4
4
|
|
|
5
|
-
export function
|
|
5
|
+
export function AgentContextDocumentInput(props: InputProps) {
|
|
6
6
|
const dataset = useDataset()
|
|
7
7
|
const projectId = useProjectId()
|
|
8
8
|
const toast = useToast()
|
|
9
9
|
|
|
10
10
|
const slug = getValueAtPath(props.value, ['slug'])
|
|
11
11
|
const currentSlug = slug && typeof slug === 'object' && 'current' in slug ? slug.current : ''
|
|
12
|
-
const MCP_URL = `https://context-mcp.sanity.io/${projectId}/${dataset}/${currentSlug}`
|
|
12
|
+
const MCP_URL = `https://context-mcp.sanity.io/mcp/${projectId}/${dataset}/${currentSlug}`
|
|
13
13
|
|
|
14
14
|
const handleCopy = () => {
|
|
15
15
|
try {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import {DatabaseIcon} from '@sanity/icons'
|
|
2
2
|
import {defineField, defineType} from 'sanity'
|
|
3
3
|
|
|
4
|
+
import {AgentContextDocumentInput} from './AgentContextDocumentInput'
|
|
5
|
+
import {GroqFilterInput} from './groq-filter-input/GroqFilterInput'
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* The name of the agent context schema type.
|
|
9
|
+
* @beta
|
|
6
10
|
*/
|
|
7
11
|
export const AGENT_CONTEXT_SCHEMA_TYPE_NAME = 'sanity.agentContext'
|
|
8
12
|
|
|
@@ -20,11 +24,15 @@ export const agentContextSchema = defineType({
|
|
|
20
24
|
title: AGENT_CONTEXT_SCHEMA_TITLE,
|
|
21
25
|
type: 'document',
|
|
22
26
|
icon: DatabaseIcon,
|
|
27
|
+
components: {
|
|
28
|
+
input: AgentContextDocumentInput,
|
|
29
|
+
},
|
|
23
30
|
fields: [
|
|
24
31
|
defineField({
|
|
25
32
|
name: 'name',
|
|
26
33
|
title: 'Name',
|
|
27
34
|
type: 'string',
|
|
35
|
+
placeholder: 'My Agent Context',
|
|
28
36
|
}),
|
|
29
37
|
defineField({
|
|
30
38
|
name: 'slug',
|
|
@@ -34,25 +42,15 @@ export const agentContextSchema = defineType({
|
|
|
34
42
|
source: 'name',
|
|
35
43
|
},
|
|
36
44
|
}),
|
|
37
|
-
defineField({
|
|
38
|
-
name: 'organizationId',
|
|
39
|
-
title: 'Organization ID',
|
|
40
|
-
type: 'string',
|
|
41
|
-
}),
|
|
42
|
-
defineField({
|
|
43
|
-
name: 'projectId',
|
|
44
|
-
title: 'Project ID',
|
|
45
|
-
type: 'string',
|
|
46
|
-
}),
|
|
47
|
-
defineField({
|
|
48
|
-
name: 'dataset',
|
|
49
|
-
title: 'Dataset',
|
|
50
|
-
type: 'string',
|
|
51
|
-
}),
|
|
52
45
|
defineField({
|
|
53
46
|
name: 'groqFilter',
|
|
54
|
-
title: '
|
|
55
|
-
|
|
47
|
+
title: 'Content filter',
|
|
48
|
+
description:
|
|
49
|
+
'Define which content AI agents can access. Pick types which will generate the filter, or manually enter the filter in the GROQ tab.',
|
|
50
|
+
type: 'string',
|
|
51
|
+
components: {
|
|
52
|
+
input: GroqFilterInput,
|
|
53
|
+
},
|
|
56
54
|
}),
|
|
57
55
|
],
|
|
58
56
|
})
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckmarkIcon,
|
|
3
|
+
ChevronDownIcon,
|
|
4
|
+
CloseIcon,
|
|
5
|
+
ErrorOutlineIcon,
|
|
6
|
+
GroqIcon,
|
|
7
|
+
ListIcon,
|
|
8
|
+
} from '@sanity/icons'
|
|
9
|
+
import {
|
|
10
|
+
Box,
|
|
11
|
+
Button,
|
|
12
|
+
Card,
|
|
13
|
+
Flex,
|
|
14
|
+
Popover,
|
|
15
|
+
Stack,
|
|
16
|
+
Tab,
|
|
17
|
+
TabList,
|
|
18
|
+
TabPanel,
|
|
19
|
+
Text,
|
|
20
|
+
TextArea,
|
|
21
|
+
TextInput,
|
|
22
|
+
Tooltip,
|
|
23
|
+
useClickOutsideEvent,
|
|
24
|
+
} from '@sanity/ui'
|
|
25
|
+
import {useCallback, useMemo, useRef, useState} from 'react'
|
|
26
|
+
import {
|
|
27
|
+
CommandList,
|
|
28
|
+
SanityDefaultPreview,
|
|
29
|
+
set,
|
|
30
|
+
type StringInputProps,
|
|
31
|
+
unset,
|
|
32
|
+
useSchema,
|
|
33
|
+
} from 'sanity'
|
|
34
|
+
|
|
35
|
+
import {isSimpleTypeQuery, listToQuery, queryToList, validateGroq} from './groqUtils'
|
|
36
|
+
import {useComposedRefs} from './useComposedRefs'
|
|
37
|
+
|
|
38
|
+
const TAB_IDS = {
|
|
39
|
+
TYPES_TAB: 'types-tab',
|
|
40
|
+
TYPES_PANEL: 'types-panel',
|
|
41
|
+
GROQ_TAB: 'groq-tab',
|
|
42
|
+
GROQ_PANEL: 'groq-panel',
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
const ITEM_HEIGHT = 43
|
|
46
|
+
|
|
47
|
+
export function GroqFilterInput(props: StringInputProps) {
|
|
48
|
+
const {value, onChange, elementProps} = props
|
|
49
|
+
const {ref: refProp, ...restElementProps} = elementProps || {}
|
|
50
|
+
|
|
51
|
+
const [open, setOpen] = useState<boolean>(false)
|
|
52
|
+
const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null)
|
|
53
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
54
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
55
|
+
const openListButtonRef = useRef<HTMLButtonElement>(null)
|
|
56
|
+
const [searchQuery, setSearchQuery] = useState<string | null>(null)
|
|
57
|
+
|
|
58
|
+
const schema = useSchema()
|
|
59
|
+
|
|
60
|
+
// Compose the input ref with the ref prop
|
|
61
|
+
const setInputRef = useCallback(
|
|
62
|
+
(node: HTMLInputElement | null) => {
|
|
63
|
+
inputRef.current = node
|
|
64
|
+
setInputElement(node)
|
|
65
|
+
},
|
|
66
|
+
[inputRef],
|
|
67
|
+
)
|
|
68
|
+
const composedRef = useComposedRefs(setInputRef, refProp)
|
|
69
|
+
|
|
70
|
+
// Check if the current query is simple enough to edit via Types UI
|
|
71
|
+
const isSimple = useMemo(() => isSimpleTypeQuery(value), [value])
|
|
72
|
+
|
|
73
|
+
// Validate GROQ syntax
|
|
74
|
+
const validation = useMemo(() => validateGroq(value), [value])
|
|
75
|
+
|
|
76
|
+
// Initialize view based on whether the current query is simple or complex
|
|
77
|
+
const [panel, setPanel] = useState<'types' | 'groq'>(() =>
|
|
78
|
+
isSimpleTypeQuery(value) ? 'types' : 'groq',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const selectedTypes = useMemo(() => {
|
|
82
|
+
if (!value) return []
|
|
83
|
+
return queryToList(value)
|
|
84
|
+
}, [value])
|
|
85
|
+
|
|
86
|
+
// Filter the type names based on the search query
|
|
87
|
+
const filteredTypeNames = useMemo(() => {
|
|
88
|
+
const types = schema._original?.types || []
|
|
89
|
+
const typeNames = types.map((type) => type.name).filter((name) => !name.startsWith('sanity.'))
|
|
90
|
+
if (!searchQuery) return typeNames
|
|
91
|
+
|
|
92
|
+
return typeNames.filter((name) => name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
93
|
+
}, [searchQuery, schema._original?.types])
|
|
94
|
+
|
|
95
|
+
// Handle document type item click.
|
|
96
|
+
// 1. If the item is already selected, remove it from the selected types.
|
|
97
|
+
// 2. If the item is not selected, add it to the selected types.
|
|
98
|
+
// 3. Transform the updated selected types into a GROQ query and set it as the new value.
|
|
99
|
+
const handleDocumentTypeItemClick = (item: string) => {
|
|
100
|
+
const nextValue = selectedTypes.includes(item)
|
|
101
|
+
? selectedTypes.filter((t) => t !== item)
|
|
102
|
+
: [...selectedTypes, item]
|
|
103
|
+
|
|
104
|
+
onChange(nextValue.length > 0 ? set(listToQuery(nextValue)) : unset())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const closeList = useCallback(() => {
|
|
108
|
+
setOpen(false)
|
|
109
|
+
setSearchQuery(null)
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
const handleToggleList = useCallback(() => {
|
|
113
|
+
if (open) {
|
|
114
|
+
closeList()
|
|
115
|
+
} else {
|
|
116
|
+
setOpen(true)
|
|
117
|
+
inputRef.current?.focus()
|
|
118
|
+
}
|
|
119
|
+
}, [open, closeList])
|
|
120
|
+
|
|
121
|
+
const handleTextInputKeyDown = useCallback(
|
|
122
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
123
|
+
if (event.key === 'Escape') {
|
|
124
|
+
closeList()
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[closeList],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const getItemSelected = useCallback(
|
|
131
|
+
(index: number) => {
|
|
132
|
+
const item = filteredTypeNames[index]
|
|
133
|
+
return item ? selectedTypes.includes(item) : false
|
|
134
|
+
},
|
|
135
|
+
[filteredTypeNames, selectedTypes],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
useClickOutsideEvent(
|
|
139
|
+
() => {
|
|
140
|
+
closeList()
|
|
141
|
+
},
|
|
142
|
+
() => [popoverRef.current, inputRef.current, openListButtonRef.current],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const isListOpen = open || searchQuery !== null
|
|
146
|
+
|
|
147
|
+
// If query is complex or invalid, force GROQ panel regardless of selection
|
|
148
|
+
const effectivePanel = !isSimple || !validation.valid ? 'groq' : panel
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Stack space={2}>
|
|
152
|
+
<TabList space={1}>
|
|
153
|
+
<Tooltip
|
|
154
|
+
animate
|
|
155
|
+
disabled={isSimple && validation.valid}
|
|
156
|
+
content={
|
|
157
|
+
<Box padding={1} style={{maxWidth: '200px'}}>
|
|
158
|
+
<Text size={1}>
|
|
159
|
+
{!validation.valid
|
|
160
|
+
? 'The current filter has a syntax error that needs to be fixed in the GROQ tab.'
|
|
161
|
+
: 'The current filter is too complex to edit here. Use the GROQ tab to edit it.'}
|
|
162
|
+
</Text>
|
|
163
|
+
</Box>
|
|
164
|
+
}
|
|
165
|
+
delay={{open: 200, close: 0}}
|
|
166
|
+
placement="bottom"
|
|
167
|
+
portal
|
|
168
|
+
>
|
|
169
|
+
<div>
|
|
170
|
+
<Tab
|
|
171
|
+
aria-controls={TAB_IDS.TYPES_PANEL}
|
|
172
|
+
disabled={!isSimple || !validation.valid}
|
|
173
|
+
icon={ListIcon}
|
|
174
|
+
id={TAB_IDS.TYPES_TAB}
|
|
175
|
+
label="Types"
|
|
176
|
+
onClick={() => isSimple && validation.valid && setPanel('types')}
|
|
177
|
+
padding={3}
|
|
178
|
+
selected={effectivePanel === 'types'}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
</Tooltip>
|
|
182
|
+
|
|
183
|
+
<Tab
|
|
184
|
+
aria-controls={TAB_IDS.GROQ_PANEL}
|
|
185
|
+
icon={GroqIcon}
|
|
186
|
+
id={TAB_IDS.GROQ_TAB}
|
|
187
|
+
label="GROQ"
|
|
188
|
+
onClick={() => setPanel('groq')}
|
|
189
|
+
padding={3}
|
|
190
|
+
selected={effectivePanel === 'groq'}
|
|
191
|
+
/>
|
|
192
|
+
</TabList>
|
|
193
|
+
|
|
194
|
+
{/* ----- Types panel ----- */}
|
|
195
|
+
<TabPanel
|
|
196
|
+
aria-labelledby={TAB_IDS.TYPES_TAB}
|
|
197
|
+
hidden={effectivePanel !== 'types'}
|
|
198
|
+
id={TAB_IDS.TYPES_PANEL}
|
|
199
|
+
tabIndex={-1}
|
|
200
|
+
>
|
|
201
|
+
<Stack space={3}>
|
|
202
|
+
<Popover
|
|
203
|
+
animate
|
|
204
|
+
constrainSize
|
|
205
|
+
fallbackPlacements={['bottom', 'top']}
|
|
206
|
+
matchReferenceWidth
|
|
207
|
+
open={isListOpen}
|
|
208
|
+
placement="bottom"
|
|
209
|
+
portal
|
|
210
|
+
ref={popoverRef}
|
|
211
|
+
content={
|
|
212
|
+
<Flex direction="column" height="fill">
|
|
213
|
+
{filteredTypeNames.length === 0 && (
|
|
214
|
+
<Flex direction="column" overflow="hidden" flex={1} padding={5}>
|
|
215
|
+
<Text align="center" size={1}>
|
|
216
|
+
No document types found matching <b>{`"${searchQuery}"`}</b>
|
|
217
|
+
</Text>
|
|
218
|
+
</Flex>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{filteredTypeNames.length > 0 && (
|
|
222
|
+
<Flex direction="column" overflow="hidden" flex={1}>
|
|
223
|
+
<CommandList
|
|
224
|
+
activeItemDataAttr="data-hovered"
|
|
225
|
+
ariaLabel="Document Types"
|
|
226
|
+
ariaMultiselectable
|
|
227
|
+
fixedHeight
|
|
228
|
+
getItemKey={(item) => item}
|
|
229
|
+
getItemSelected={getItemSelected}
|
|
230
|
+
inputElement={inputElement}
|
|
231
|
+
itemHeight={ITEM_HEIGHT}
|
|
232
|
+
padding={1}
|
|
233
|
+
items={filteredTypeNames}
|
|
234
|
+
renderItem={(documentTypeName) => {
|
|
235
|
+
const isSelected = selectedTypes.includes(documentTypeName)
|
|
236
|
+
const schemaType = schema.get(documentTypeName)
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Stack key={documentTypeName} padding={1}>
|
|
240
|
+
<Button
|
|
241
|
+
aria-label={`${isSelected ? 'Remove' : 'Add'} ${documentTypeName} to filter`}
|
|
242
|
+
aria-selected={isSelected}
|
|
243
|
+
mode="bleed"
|
|
244
|
+
onClick={() => handleDocumentTypeItemClick(documentTypeName)}
|
|
245
|
+
padding={0}
|
|
246
|
+
>
|
|
247
|
+
<Flex align="center" gap={2}>
|
|
248
|
+
<Box flex={1}>
|
|
249
|
+
<SanityDefaultPreview
|
|
250
|
+
layout="compact"
|
|
251
|
+
title={schemaType?.title || documentTypeName}
|
|
252
|
+
schemaType={schemaType}
|
|
253
|
+
icon={schemaType?.icon}
|
|
254
|
+
/>
|
|
255
|
+
</Box>
|
|
256
|
+
|
|
257
|
+
{isSelected && (
|
|
258
|
+
<Box paddingX={3}>
|
|
259
|
+
<Text size={1}>
|
|
260
|
+
<CheckmarkIcon />
|
|
261
|
+
</Text>
|
|
262
|
+
</Box>
|
|
263
|
+
)}
|
|
264
|
+
</Flex>
|
|
265
|
+
</Button>
|
|
266
|
+
</Stack>
|
|
267
|
+
)
|
|
268
|
+
}}
|
|
269
|
+
/>
|
|
270
|
+
</Flex>
|
|
271
|
+
)}
|
|
272
|
+
</Flex>
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<Card display="flex" border radius={2} overflow="hidden">
|
|
276
|
+
<Card flex={1} borderRight>
|
|
277
|
+
<TextInput
|
|
278
|
+
{...restElementProps}
|
|
279
|
+
autoComplete="off"
|
|
280
|
+
border={false}
|
|
281
|
+
onChange={(event) => setSearchQuery(event.currentTarget.value)}
|
|
282
|
+
onKeyDown={handleTextInputKeyDown}
|
|
283
|
+
placeholder="Search for document types"
|
|
284
|
+
radius={0}
|
|
285
|
+
ref={composedRef}
|
|
286
|
+
value={searchQuery || ''}
|
|
287
|
+
/>
|
|
288
|
+
</Card>
|
|
289
|
+
|
|
290
|
+
<Flex align="center" justify="center" sizing="border" padding={1} height="fill">
|
|
291
|
+
<Button
|
|
292
|
+
aria-label="Open document types list"
|
|
293
|
+
disabled={isListOpen}
|
|
294
|
+
icon={ChevronDownIcon}
|
|
295
|
+
mode="bleed"
|
|
296
|
+
onClick={handleToggleList}
|
|
297
|
+
padding={2}
|
|
298
|
+
ref={openListButtonRef}
|
|
299
|
+
/>
|
|
300
|
+
</Flex>
|
|
301
|
+
</Card>
|
|
302
|
+
</Popover>
|
|
303
|
+
|
|
304
|
+
<Flex wrap="wrap" gap={2}>
|
|
305
|
+
{selectedTypes.map((type) => {
|
|
306
|
+
const title = schema.get(type)?.title || type
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<Card key={type} padding={1} radius={3} border tone="transparent" paddingLeft={2}>
|
|
310
|
+
<Flex align="center" gap={1} overflow="hidden">
|
|
311
|
+
<Box flex={1}>
|
|
312
|
+
<Text size={1} weight="medium" textOverflow="ellipsis">
|
|
313
|
+
{title}
|
|
314
|
+
</Text>
|
|
315
|
+
</Box>
|
|
316
|
+
|
|
317
|
+
<Button
|
|
318
|
+
aria-label="Remove {type} from filter"
|
|
319
|
+
fontSize={0}
|
|
320
|
+
icon={CloseIcon}
|
|
321
|
+
mode="bleed"
|
|
322
|
+
onClick={() => handleDocumentTypeItemClick(type)}
|
|
323
|
+
padding={2}
|
|
324
|
+
/>
|
|
325
|
+
</Flex>
|
|
326
|
+
</Card>
|
|
327
|
+
)
|
|
328
|
+
})}
|
|
329
|
+
</Flex>
|
|
330
|
+
</Stack>
|
|
331
|
+
</TabPanel>
|
|
332
|
+
|
|
333
|
+
{/* ----- GROQ panel ----- */}
|
|
334
|
+
<TabPanel
|
|
335
|
+
aria-labelledby={TAB_IDS.GROQ_TAB}
|
|
336
|
+
hidden={effectivePanel !== 'groq'}
|
|
337
|
+
id={TAB_IDS.GROQ_PANEL}
|
|
338
|
+
tabIndex={-1}
|
|
339
|
+
>
|
|
340
|
+
<Stack space={3}>
|
|
341
|
+
<TextArea
|
|
342
|
+
{...restElementProps}
|
|
343
|
+
onChange={(event) =>
|
|
344
|
+
onChange(event.currentTarget.value ? set(event.currentTarget.value) : unset())
|
|
345
|
+
}
|
|
346
|
+
placeholder='_type in ["author", "post"]'
|
|
347
|
+
value={value || ''}
|
|
348
|
+
style={{fontFamily: 'monospace'}}
|
|
349
|
+
padding={4}
|
|
350
|
+
/>
|
|
351
|
+
</Stack>
|
|
352
|
+
</TabPanel>
|
|
353
|
+
|
|
354
|
+
{/* ----- Result and validation errors ----- */}
|
|
355
|
+
|
|
356
|
+
{!validation.valid && (
|
|
357
|
+
<Card padding={3} radius={2} tone="critical" border>
|
|
358
|
+
<Flex align="flex-start" gap={2}>
|
|
359
|
+
<Text size={1}>
|
|
360
|
+
<ErrorOutlineIcon />
|
|
361
|
+
</Text>
|
|
362
|
+
|
|
363
|
+
<Text size={1}>{validation.error}</Text>
|
|
364
|
+
</Flex>
|
|
365
|
+
</Card>
|
|
366
|
+
)}
|
|
367
|
+
</Stack>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {isSimpleTypeQuery, listToQuery, queryToList, validateGroq} from './groqUtils'
|
|
4
|
+
|
|
5
|
+
describe('groqUtils', () => {
|
|
6
|
+
describe('listToQuery', () => {
|
|
7
|
+
it('should convert a list of types to a GROQ query', () => {
|
|
8
|
+
expect(listToQuery(['author'])).toBe('_type in ["author"]')
|
|
9
|
+
expect(listToQuery(['author', 'book'])).toBe('_type in ["author", "book"]')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should return empty brackets for empty list', () => {
|
|
13
|
+
expect(listToQuery([])).toBe('_type in []')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('queryToList', () => {
|
|
18
|
+
it('should parse types from a GROQ query', () => {
|
|
19
|
+
expect(queryToList('_type in ["author"]')).toEqual(['author'])
|
|
20
|
+
expect(queryToList('_type in ["author", "book"]')).toEqual(['author', 'book'])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should handle queries with extra whitespace', () => {
|
|
24
|
+
expect(queryToList('_type in ["author" , "book"]')).toEqual(['author', 'book'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should return empty array for non-matching queries', () => {
|
|
28
|
+
expect(queryToList('_type == "author"')).toEqual([])
|
|
29
|
+
expect(queryToList('')).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('isSimpleTypeQuery', () => {
|
|
34
|
+
it('should return true for simple _type in [...] queries', () => {
|
|
35
|
+
expect(isSimpleTypeQuery('_type in ["author"]')).toBe(true)
|
|
36
|
+
expect(isSimpleTypeQuery('_type in ["author", "book"]')).toBe(true)
|
|
37
|
+
expect(isSimpleTypeQuery('_type in []')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should return true for undefined/empty queries', () => {
|
|
41
|
+
expect(isSimpleTypeQuery(undefined)).toBe(true)
|
|
42
|
+
expect(isSimpleTypeQuery('')).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should return false for complex queries', () => {
|
|
46
|
+
expect(isSimpleTypeQuery('_type in ["author"] && published')).toBe(false)
|
|
47
|
+
expect(isSimpleTypeQuery('_type == "author"')).toBe(false)
|
|
48
|
+
expect(isSimpleTypeQuery('*[_type in ["author"]]')).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('validateGroq', () => {
|
|
53
|
+
it('should return valid for correct GROQ syntax', () => {
|
|
54
|
+
expect(validateGroq('_type in ["author"]')).toEqual({valid: true})
|
|
55
|
+
expect(validateGroq('_type == "author" && published')).toEqual({valid: true})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should return valid for undefined/empty queries', () => {
|
|
59
|
+
expect(validateGroq(undefined)).toEqual({valid: true})
|
|
60
|
+
expect(validateGroq('')).toEqual({valid: true})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should return invalid with error for bad GROQ syntax', () => {
|
|
64
|
+
const result = validateGroq('_type in ["author"')
|
|
65
|
+
expect(result.valid).toBe(false)
|
|
66
|
+
expect(result.error).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|