sanity-plugin-references 1.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 +21 -0
- package/README.md +118 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/package.json +105 -0
- package/sanity.json +8 -0
- package/src/badge.tsx +49 -0
- package/src/index.ts +120 -0
- package/src/pane.tsx +230 -0
- package/v2-incompatible.js +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Félix Péault (Flayks)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<h2 align="center">
|
|
2
|
+
🔗 References Plugin for Sanity 🔗
|
|
3
|
+
</h2>
|
|
4
|
+
<p align="center">
|
|
5
|
+
See which documents reference the current document in your Sanity Studio.<br/>
|
|
6
|
+
Displays a badge with the reference count and a custom pane with the full list.
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Reference Badge**: Shows count of documents referencing the current document
|
|
12
|
+
- **References Tab**: Lists all referencing documents with:
|
|
13
|
+
- 🔍 **Search** by title or document type
|
|
14
|
+
- ⬇️ **Sorting** by updated date, type, or title (ascending/descending)
|
|
15
|
+
- 📊 **Live Count** of filtered vs total documents
|
|
16
|
+
- 🚀 **Click to Navigate** to any referencing document
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
# npm
|
|
22
|
+
npm i sanity-plugin-references
|
|
23
|
+
|
|
24
|
+
# yarn
|
|
25
|
+
yarn add sanity-plugin-references
|
|
26
|
+
|
|
27
|
+
# pnpm
|
|
28
|
+
pnpm add sanity-plugin-references
|
|
29
|
+
|
|
30
|
+
# bun
|
|
31
|
+
bun add sanity-plugin-references
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Basic Setup
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { defineConfig } from 'sanity'
|
|
40
|
+
import { references } from 'sanity-plugin-references'
|
|
41
|
+
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
plugins: [references()],
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This adds a **reference count badge** to all documents showing how many other documents reference them.
|
|
48
|
+
|
|
49
|
+
### Adding the References Tab
|
|
50
|
+
|
|
51
|
+
Add a full References tab with search, filters, and sorting:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { structureTool } from 'sanity/structure'
|
|
55
|
+
import { references, referencesView } from 'sanity-plugin-references'
|
|
56
|
+
|
|
57
|
+
export default defineConfig({
|
|
58
|
+
plugins: [
|
|
59
|
+
references(),
|
|
60
|
+
structureTool({
|
|
61
|
+
defaultDocumentNode: (S) => S.document().views([
|
|
62
|
+
S.view.form(),
|
|
63
|
+
referencesView(S),
|
|
64
|
+
]),
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
**Exclude document types** from showing the badge:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
references({
|
|
76
|
+
exclude: ['media.tag', 'sanity.imageAsset'],
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Customize the tab** title or icon:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
referencesView(S, { title: 'Incoming Links', icon: SomeIcon })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Show tab only for specific types** by checking `schemaType` in `defaultDocumentNode`.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `references(config?)`
|
|
91
|
+
|
|
92
|
+
Main plugin function. Adds reference badges to all documents.
|
|
93
|
+
|
|
94
|
+
- `exclude?: string[]` - Document types to exclude from showing the badge
|
|
95
|
+
|
|
96
|
+
### `referencesView(S, options?)`
|
|
97
|
+
|
|
98
|
+
Creates a References view for Structure Builder.
|
|
99
|
+
|
|
100
|
+
- `title?: string` - Tab title (default: `'References'`)
|
|
101
|
+
- `icon?: ComponentType` - Tab icon (default: `LinkIcon`)
|
|
102
|
+
|
|
103
|
+
### Components
|
|
104
|
+
|
|
105
|
+
- **`ReferencesPane`** - Raw component for `S.view.component()` (use `referencesView()` instead)
|
|
106
|
+
- **`ReferencesBadge`** - Badge component (automatically included via `references()` plugin)
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
[MIT](LICENSE) © Félix Péault (Flayks)
|
|
111
|
+
|
|
112
|
+
## Develop & test
|
|
113
|
+
|
|
114
|
+
This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
|
|
115
|
+
with default configuration for build & watch scripts.
|
|
116
|
+
|
|
117
|
+
See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
|
|
118
|
+
on how to run this plugin with hotreload in the studio.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type {ComponentType} from 'react'
|
|
2
|
+
import {ComponentViewBuilder} from 'sanity/structure'
|
|
3
|
+
import {DocumentBadgeComponent} from 'sanity'
|
|
4
|
+
import {Plugin as Plugin_2} from 'sanity'
|
|
5
|
+
import {LinkIcon as ReferencesIcon} from '@sanity/icons'
|
|
6
|
+
import type {StructureBuilder} from 'sanity/structure'
|
|
7
|
+
import {UserViewComponent} from 'sanity/structure'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plugin that shows documents referencing the current document.
|
|
11
|
+
* Displays a badge with the reference count.
|
|
12
|
+
*
|
|
13
|
+
* To add the References tab to your documents, use the Structure Builder API
|
|
14
|
+
* with the exported `ReferencesPane` component.
|
|
15
|
+
*
|
|
16
|
+
* @example Basic usage (badge only)
|
|
17
|
+
* ```ts
|
|
18
|
+
* // sanity.config.ts
|
|
19
|
+
* import { references } from 'sanity-plugin-references'
|
|
20
|
+
*
|
|
21
|
+
* export default defineConfig({
|
|
22
|
+
* plugins: [references()],
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example Exclude specific document types from showing the badge
|
|
27
|
+
* ```ts
|
|
28
|
+
* references({ exclude: ['media.tag', 'sanity.imageAsset'] })
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @example Adding the References tab with Structure Builder
|
|
32
|
+
* ```ts
|
|
33
|
+
* // sanity.config.ts
|
|
34
|
+
* import { structureTool } from 'sanity/structure'
|
|
35
|
+
* import { references, ReferencesPane } from 'sanity-plugin-references'
|
|
36
|
+
* import { LinkIcon } from '@sanity/icons'
|
|
37
|
+
*
|
|
38
|
+
* export default defineConfig({
|
|
39
|
+
* plugins: [
|
|
40
|
+
* references(),
|
|
41
|
+
* structureTool({
|
|
42
|
+
* defaultDocumentNode: (S, { schemaType }) => {
|
|
43
|
+
* return S.document().views([
|
|
44
|
+
* S.view.form(),
|
|
45
|
+
* S.view.component(ReferencesPane).title('References').icon(LinkIcon),
|
|
46
|
+
* ])
|
|
47
|
+
* },
|
|
48
|
+
* }),
|
|
49
|
+
* ],
|
|
50
|
+
* })
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @example Using the referencesView helper
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { references, referencesView } from 'sanity-plugin-references'
|
|
56
|
+
*
|
|
57
|
+
* structureTool({
|
|
58
|
+
* defaultDocumentNode: (S) => S.document().views([
|
|
59
|
+
* S.view.form(),
|
|
60
|
+
* referencesView(S),
|
|
61
|
+
* ]),
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare const references: Plugin_2<void | ReferencesConfig>
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Badge component that displays the count of documents referencing the current document
|
|
69
|
+
*/
|
|
70
|
+
export declare const ReferencesBadge: DocumentBadgeComponent
|
|
71
|
+
|
|
72
|
+
export declare interface ReferencesConfig {
|
|
73
|
+
/**
|
|
74
|
+
* Document types to exclude from showing the References badge
|
|
75
|
+
* @example ['media.tag', 'sanity.imageAsset']
|
|
76
|
+
*/
|
|
77
|
+
exclude?: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export {ReferencesIcon}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Structure Builder view component that displays documents referencing the current document.
|
|
84
|
+
* Use this component with S.view.component() in your structure configuration.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { references, referencesView } from 'sanity-plugin-references'
|
|
89
|
+
* import { LinkIcon } from '@sanity/icons'
|
|
90
|
+
*
|
|
91
|
+
* S.document().views([
|
|
92
|
+
* S.view.form(),
|
|
93
|
+
* referencesView(S),
|
|
94
|
+
* ])
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export declare const ReferencesPane: UserViewComponent
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Helper to create a References view for Structure Builder.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* S.document().views([
|
|
105
|
+
* S.view.form(),
|
|
106
|
+
* referencesView(S),
|
|
107
|
+
* ])
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* @example With custom title and icon
|
|
111
|
+
* ```ts
|
|
112
|
+
* referencesView(S, { title: 'Incoming Links', icon: MyIcon })
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export declare function referencesView(
|
|
116
|
+
S: StructureBuilder,
|
|
117
|
+
options?: {
|
|
118
|
+
title?: string
|
|
119
|
+
icon?: ComponentType
|
|
120
|
+
},
|
|
121
|
+
): ComponentViewBuilder
|
|
122
|
+
|
|
123
|
+
export {}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useClient, useSchema, Preview, definePlugin } from "sanity";
|
|
2
|
+
import { LinkIcon, SortIcon, SearchIcon } from "@sanity/icons";
|
|
3
|
+
import { LinkIcon as LinkIcon2 } from "@sanity/icons";
|
|
4
|
+
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { useRouter } from "sanity/router";
|
|
7
|
+
import { Flex, Spinner, Card, Text, Stack, Select, Inline, Radio, Box, Button, TextInput, Badge } from "@sanity/ui";
|
|
8
|
+
const ReferencesBadge = (props) => {
|
|
9
|
+
const { id, published, draft } = props, documentId = published?._id || draft?._id || id, [count, setCount] = useState(null), client = useClient({ apiVersion: "2026-01-05" });
|
|
10
|
+
return useEffect(() => {
|
|
11
|
+
if (!documentId) return;
|
|
12
|
+
const cleanId = documentId.replace(/^drafts\./, "");
|
|
13
|
+
client.fetch(
|
|
14
|
+
/* groq */
|
|
15
|
+
'count(*[references($id) && !(_id in path("drafts.**"))])',
|
|
16
|
+
{ id: cleanId }
|
|
17
|
+
).then(setCount);
|
|
18
|
+
const subscription = client.listen(
|
|
19
|
+
/* groq */
|
|
20
|
+
"*[references($id)]",
|
|
21
|
+
{ id: cleanId }
|
|
22
|
+
).subscribe(() => {
|
|
23
|
+
client.fetch(
|
|
24
|
+
/* groq */
|
|
25
|
+
'count(*[references($id) && !(_id in path("drafts.**"))])',
|
|
26
|
+
{ id: cleanId }
|
|
27
|
+
).then(setCount);
|
|
28
|
+
});
|
|
29
|
+
return () => subscription.unsubscribe();
|
|
30
|
+
}, [documentId, client]), count === null || count === 0 ? null : {
|
|
31
|
+
label: `${count} reference${count > 1 ? "s" : ""}`,
|
|
32
|
+
title: `${count} document${count > 1 ? "s" : ""} reference this. See "References" tab.`,
|
|
33
|
+
color: "primary"
|
|
34
|
+
};
|
|
35
|
+
}, ReferencesPane = (props) => {
|
|
36
|
+
const { documentId } = props, client = useClient({ apiVersion: "2026-01-05" }), schema = useSchema(), router = useRouter(), [documents, setDocuments] = useState([]), [loading, setLoading] = useState(!0), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState(""), [typeFilter, setTypeFilter] = useState("all"), [sortBy, setSortBy] = useState("updatedAt"), [sortDesc, setSortDesc] = useState(!0), cleanId = useMemo(() => documentId.replace(/^drafts\./, ""), [documentId]), fetchDocuments = useCallback(async () => {
|
|
37
|
+
if (cleanId) {
|
|
38
|
+
setLoading(!0), setError(null);
|
|
39
|
+
try {
|
|
40
|
+
const results = await client.fetch(
|
|
41
|
+
`*[references($id) && !(_id in path("drafts.**"))] {
|
|
42
|
+
_id, _type, _updatedAt, "title": coalesce(title, name, "Untitled")
|
|
43
|
+
} | order(_updatedAt desc)`,
|
|
44
|
+
{ id: cleanId }
|
|
45
|
+
);
|
|
46
|
+
setDocuments(results);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setError(err instanceof Error ? err.message : "Failed to fetch references");
|
|
49
|
+
} finally {
|
|
50
|
+
setLoading(!1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}, [client, cleanId]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
fetchDocuments();
|
|
56
|
+
}, [fetchDocuments]);
|
|
57
|
+
const documentTypes = useMemo(
|
|
58
|
+
() => [...new Set(documents.map((doc) => doc._type))].sort(),
|
|
59
|
+
[documents]
|
|
60
|
+
), filteredDocuments = useMemo(() => {
|
|
61
|
+
const query = searchQuery.toLowerCase().trim();
|
|
62
|
+
return documents.filter((doc) => typeFilter === "all" || doc._type === typeFilter).filter((doc) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query)).sort((a, b) => {
|
|
63
|
+
const cmp = sortBy === "updatedAt" ? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime() : sortBy === "type" ? a._type.localeCompare(b._type) : (a.title || "").localeCompare(b.title || "");
|
|
64
|
+
return sortDesc ? -cmp : cmp;
|
|
65
|
+
});
|
|
66
|
+
}, [documents, typeFilter, searchQuery, sortBy, sortDesc]), getSchemaType = useCallback((type) => schema.get(type), [schema]);
|
|
67
|
+
if (loading)
|
|
68
|
+
return /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", padding: 5, children: /* @__PURE__ */ jsx(Spinner, { muted: !0 }) });
|
|
69
|
+
if (error)
|
|
70
|
+
return /* @__PURE__ */ jsx(Card, { padding: 4, tone: "critical", children: /* @__PURE__ */ jsx(Text, { children: error }) });
|
|
71
|
+
if (documents.length === 0)
|
|
72
|
+
return /* @__PURE__ */ jsx(Card, { padding: 5, children: /* @__PURE__ */ jsxs(Flex, { align: "center", justify: "center", direction: "column", gap: 3, children: [
|
|
73
|
+
/* @__PURE__ */ jsx(LinkIcon, { style: { fontSize: 32, opacity: 0.4 } }),
|
|
74
|
+
/* @__PURE__ */ jsx(Text, { muted: !0, children: "No document reference this one" })
|
|
75
|
+
] }) });
|
|
76
|
+
const useSelectForFilters = documentTypes.length > 5;
|
|
77
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 0, style: { height: "100%" }, children: [
|
|
78
|
+
/* @__PURE__ */ jsx(Card, { borderBottom: !0, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "nowrap", children: [
|
|
79
|
+
useSelectForFilters ? /* @__PURE__ */ jsxs(
|
|
80
|
+
Select,
|
|
81
|
+
{
|
|
82
|
+
value: typeFilter,
|
|
83
|
+
onChange: (e) => setTypeFilter(e.currentTarget.value),
|
|
84
|
+
fontSize: 1,
|
|
85
|
+
padding: 2,
|
|
86
|
+
style: { minWidth: 150, flexShrink: 0 },
|
|
87
|
+
children: [
|
|
88
|
+
/* @__PURE__ */ jsx("option", { value: "all", children: "All types" }),
|
|
89
|
+
documentTypes.map((type) => {
|
|
90
|
+
const schemaType = getSchemaType(type);
|
|
91
|
+
return /* @__PURE__ */ jsx("option", { value: type, children: schemaType?.title || type }, type);
|
|
92
|
+
})
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
) : /* @__PURE__ */ jsxs(Inline, { space: 2, style: { flexShrink: 0 }, children: [
|
|
96
|
+
/* @__PURE__ */ jsxs(Flex, { align: "center", gap: 1, as: "label", style: { cursor: "pointer", whiteSpace: "nowrap" }, children: [
|
|
97
|
+
/* @__PURE__ */ jsx(Radio, { checked: typeFilter === "all", onChange: () => setTypeFilter("all") }),
|
|
98
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: typeFilter === "all" ? "semibold" : "regular", children: "All" })
|
|
99
|
+
] }),
|
|
100
|
+
documentTypes.map((type) => {
|
|
101
|
+
const schemaType = getSchemaType(type);
|
|
102
|
+
return /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 1, as: "label", style: { cursor: "pointer", whiteSpace: "nowrap" }, children: [
|
|
103
|
+
/* @__PURE__ */ jsx(Radio, { checked: typeFilter === type, onChange: () => setTypeFilter(type) }),
|
|
104
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: typeFilter === type ? "semibold" : "regular", children: schemaType?.title || type })
|
|
105
|
+
] }, type);
|
|
106
|
+
})
|
|
107
|
+
] }),
|
|
108
|
+
/* @__PURE__ */ jsx(Box, { flex: 1 }),
|
|
109
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 1, style: { flexShrink: 0 }, children: [
|
|
110
|
+
/* @__PURE__ */ jsxs(Select, { value: sortBy, onChange: (e) => setSortBy(e.currentTarget.value), fontSize: 1, padding: 2, children: [
|
|
111
|
+
/* @__PURE__ */ jsx("option", { value: "updatedAt", children: "Updated" }),
|
|
112
|
+
/* @__PURE__ */ jsx("option", { value: "type", children: "Type" }),
|
|
113
|
+
/* @__PURE__ */ jsx("option", { value: "title", children: "Title" })
|
|
114
|
+
] }),
|
|
115
|
+
/* @__PURE__ */ jsx(
|
|
116
|
+
Button,
|
|
117
|
+
{
|
|
118
|
+
icon: SortIcon,
|
|
119
|
+
mode: "ghost",
|
|
120
|
+
onClick: () => setSortDesc((d) => !d),
|
|
121
|
+
title: sortDesc ? "Descending" : "Ascending",
|
|
122
|
+
fontSize: 1,
|
|
123
|
+
padding: 2
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
] })
|
|
127
|
+
] }) }),
|
|
128
|
+
/* @__PURE__ */ jsx(Card, { borderBottom: !0, paddingX: 3, paddingY: 0, children: /* @__PURE__ */ jsxs(Flex, { align: "stretch", style: { minHeight: 33 }, children: [
|
|
129
|
+
/* @__PURE__ */ jsx(Flex, { align: "center", paddingY: 2, style: { flexShrink: 0 }, children: /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
|
|
130
|
+
filteredDocuments.length,
|
|
131
|
+
" of ",
|
|
132
|
+
documents.length,
|
|
133
|
+
" document",
|
|
134
|
+
documents.length !== 1 ? "s" : ""
|
|
135
|
+
] }) }),
|
|
136
|
+
/* @__PURE__ */ jsx(Box, { style: { flex: 1, minWidth: 180, borderLeft: "1px solid var(--card-border-color)", paddingLeft: 4, marginLeft: 12, display: "flex", alignItems: "center" }, children: /* @__PURE__ */ jsx(
|
|
137
|
+
TextInput,
|
|
138
|
+
{
|
|
139
|
+
icon: SearchIcon,
|
|
140
|
+
placeholder: "Search...",
|
|
141
|
+
value: searchQuery,
|
|
142
|
+
onChange: (e) => setSearchQuery(e.currentTarget.value),
|
|
143
|
+
fontSize: 1,
|
|
144
|
+
padding: 2,
|
|
145
|
+
border: !1,
|
|
146
|
+
style: { boxShadow: "none", width: "100%" }
|
|
147
|
+
}
|
|
148
|
+
) })
|
|
149
|
+
] }) }),
|
|
150
|
+
/* @__PURE__ */ jsx(Box, { style: { flex: 1, overflow: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 1, padding: 2, children: filteredDocuments.length === 0 ? /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsx(Text, { muted: !0, align: "center", children: "No documents match your filters" }) }) : filteredDocuments.map((doc) => {
|
|
151
|
+
const schemaType = getSchemaType(doc._type);
|
|
152
|
+
return /* @__PURE__ */ jsx(
|
|
153
|
+
Card,
|
|
154
|
+
{
|
|
155
|
+
as: "button",
|
|
156
|
+
padding: 2,
|
|
157
|
+
radius: 2,
|
|
158
|
+
onClick: () => router.navigateIntent("edit", { id: doc._id, type: doc._type }),
|
|
159
|
+
style: { cursor: "pointer", textAlign: "left", width: "100%" },
|
|
160
|
+
children: /* @__PURE__ */ jsxs(Flex, { gap: 3, align: "center", children: [
|
|
161
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: schemaType ? /* @__PURE__ */ jsx(Preview, { schemaType, value: doc, layout: "default" }) : /* @__PURE__ */ jsx(Text, { weight: "medium", children: doc.title }) }),
|
|
162
|
+
/* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: schemaType?.title || doc._type })
|
|
163
|
+
] })
|
|
164
|
+
},
|
|
165
|
+
doc._id
|
|
166
|
+
);
|
|
167
|
+
}) }) })
|
|
168
|
+
] });
|
|
169
|
+
}, references = definePlugin((config) => {
|
|
170
|
+
const excludeTypes = config?.exclude || [];
|
|
171
|
+
return {
|
|
172
|
+
name: "references",
|
|
173
|
+
document: {
|
|
174
|
+
badges: (prev, context) => excludeTypes.includes(context.schemaType) ? prev : [...prev, ReferencesBadge]
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
function referencesView(S, options) {
|
|
179
|
+
return S.view.component(ReferencesPane).title(options?.title || "References").icon(options?.icon || LinkIcon);
|
|
180
|
+
}
|
|
181
|
+
export {
|
|
182
|
+
ReferencesBadge,
|
|
183
|
+
LinkIcon2 as ReferencesIcon,
|
|
184
|
+
ReferencesPane,
|
|
185
|
+
references,
|
|
186
|
+
referencesView
|
|
187
|
+
};
|
|
188
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/badge.tsx","../src/pane.tsx","../src/index.ts"],"sourcesContent":["import { useState, useEffect } from 'react'\nimport { type DocumentBadgeComponent, useClient } from 'sanity'\n\n/**\n * Badge component that displays the count of documents referencing the current document\n */\nexport const ReferencesBadge: DocumentBadgeComponent = (props) => {\n const { id, published, draft } = props\n const documentId = published?._id || draft?._id || id\n const [count, setCount] = useState<number | null>(null)\n\n const client = useClient({ apiVersion: '2026-01-05' })\n\n useEffect(() => {\n if (!documentId) return\n\n const cleanId = documentId.replace(/^drafts\\./, '')\n\n // Get count of documents referencing this document\n client.fetch<number>(\n /* groq */`count(*[references($id) && !(_id in path(\"drafts.**\"))])`,\n { id: cleanId }\n ).then(setCount)\n\n // Subscribe to changes\n const subscription = client.listen(\n /* groq */`*[references($id)]`,\n { id: cleanId }\n ).subscribe(() => {\n client.fetch<number>(\n /* groq */`count(*[references($id) && !(_id in path(\"drafts.**\"))])`,\n { id: cleanId }\n ).then(setCount)\n })\n\n return () => subscription.unsubscribe()\n }, [documentId, client])\n\n // Don't show badge if no references or still loading\n if (count === null || count === 0) {\n return null\n }\n\n return {\n label: `${count} reference${count > 1 ? 's' : ''}`,\n title: `${count} document${count > 1 ? 's' : ''} reference this. See \"References\" tab.`,\n color: 'primary',\n }\n}\n","import { useMemo, useState, useEffect, useCallback } from 'react'\nimport { useClient, useSchema, Preview } from 'sanity'\nimport { type UserViewComponent } from 'sanity/structure'\nimport { useRouter } from 'sanity/router'\nimport { Box, Card, Flex, Inline, Stack, Text, TextInput, Select, Spinner, Button, Badge, Radio } from '@sanity/ui'\nimport { SearchIcon, SortIcon, LinkIcon } from '@sanity/icons'\n\ninterface ReferencingDocument {\n _id: string\n _type: string\n _updatedAt: string\n title?: string\n}\ntype SortOption = 'updatedAt' | 'type' | 'title'\n\n/**\n * Structure Builder view component that displays documents referencing the current document.\n * Use this component with S.view.component() in your structure configuration.\n *\n * @example\n * ```ts\n * import { references, referencesView } from 'sanity-plugin-references'\n * import { LinkIcon } from '@sanity/icons'\n *\n * S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ])\n * ```\n */\nexport const ReferencesPane: UserViewComponent = (props) => {\n const { documentId } = props\n const client = useClient({ apiVersion: '2026-01-05' })\n const schema = useSchema()\n const router = useRouter()\n\n const [documents, setDocuments] = useState<ReferencingDocument[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState<string | null>(null)\n const [searchQuery, setSearchQuery] = useState('')\n const [typeFilter, setTypeFilter] = useState('all')\n const [sortBy, setSortBy] = useState<SortOption>('updatedAt')\n const [sortDesc, setSortDesc] = useState(true)\n\n const cleanId = useMemo(() => documentId.replace(/^drafts\\./, ''), [documentId])\n\n const fetchDocuments = useCallback(async () => {\n if (!cleanId) return\n setLoading(true)\n setError(null)\n try {\n const results = await client.fetch<ReferencingDocument[]>(\n `*[references($id) && !(_id in path(\"drafts.**\"))] {\n _id, _type, _updatedAt, \"title\": coalesce(title, name, \"Untitled\")\n } | order(_updatedAt desc)`,\n { id: cleanId }\n )\n setDocuments(results)\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch references')\n } finally {\n setLoading(false)\n }\n }, [client, cleanId])\n\n useEffect(() => { fetchDocuments() }, [fetchDocuments])\n\n const documentTypes = useMemo(() =>\n [...new Set(documents.map((doc: ReferencingDocument) => doc._type))].sort(),\n [documents]\n )\n\n const filteredDocuments = useMemo(() => {\n const query = searchQuery.toLowerCase().trim()\n return documents\n .filter((doc: ReferencingDocument) => typeFilter === 'all' || doc._type === typeFilter)\n .filter((doc: ReferencingDocument) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query))\n .sort((a: ReferencingDocument, b: ReferencingDocument) => {\n const cmp = sortBy === 'updatedAt'\n ? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime()\n : sortBy === 'type'\n ? a._type.localeCompare(b._type)\n : (a.title || '').localeCompare(b.title || '')\n return sortDesc ? -cmp : cmp\n })\n }, [documents, typeFilter, searchQuery, sortBy, sortDesc])\n\n const getSchemaType = useCallback((type: string) => schema.get(type), [schema])\n\n if (loading) {\n return <Flex align=\"center\" justify=\"center\" padding={5}><Spinner muted /></Flex>\n }\n\n if (error) {\n return <Card padding={4} tone=\"critical\"><Text>{error}</Text></Card>\n }\n\n if (documents.length === 0) {\n return (\n <Card padding={5}>\n <Flex align=\"center\" justify=\"center\" direction=\"column\" gap={3}>\n <LinkIcon style={{ fontSize: 32, opacity: 0.4 }} />\n <Text muted>No document reference this one</Text>\n </Flex>\n </Card>\n )\n }\n\n const useSelectForFilters = documentTypes.length > 5\n\n return (\n <Stack space={0} style={{ height: '100%' }}>\n {/* Toolbar */}\n <Card borderBottom padding={2}>\n <Flex gap={2} align=\"center\" wrap=\"nowrap\">\n {useSelectForFilters ? (\n <Select\n value={typeFilter}\n onChange={e => setTypeFilter(e.currentTarget.value)}\n fontSize={1}\n padding={2}\n style={{ minWidth: 150, flexShrink: 0 }}\n >\n <option value=\"all\">All types</option>\n {documentTypes.map((type: string) => {\n const schemaType = getSchemaType(type)\n return (\n <option key={type} value={type}>\n {schemaType?.title || type}\n </option>\n )\n })}\n </Select>\n ) : (\n <Inline space={2} style={{ flexShrink: 0 }}>\n <Flex align=\"center\" gap={1} as=\"label\" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>\n <Radio checked={typeFilter === 'all'} onChange={() => setTypeFilter('all')} />\n <Text size={1} weight={typeFilter === 'all' ? 'semibold' : 'regular'}>All</Text>\n </Flex>\n {documentTypes.map((type: string) => {\n const schemaType = getSchemaType(type)\n return (\n <Flex key={type} align=\"center\" gap={1} as=\"label\" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>\n <Radio checked={typeFilter === type} onChange={() => setTypeFilter(type)} />\n <Text size={1} weight={typeFilter === type ? 'semibold' : 'regular'}>\n {schemaType?.title || type}\n </Text>\n </Flex>\n )\n })}\n </Inline>\n )}\n <Box flex={1} />\n <Flex gap={1} style={{ flexShrink: 0 }}>\n <Select value={sortBy} onChange={e => setSortBy(e.currentTarget.value as SortOption)} fontSize={1} padding={2}>\n <option value=\"updatedAt\">Updated</option>\n <option value=\"type\">Type</option>\n <option value=\"title\">Title</option>\n </Select>\n <Button\n icon={SortIcon}\n mode=\"ghost\"\n onClick={() => setSortDesc((d: boolean) => !d)}\n title={sortDesc ? 'Descending' : 'Ascending'}\n fontSize={1}\n padding={2}\n />\n </Flex>\n </Flex>\n </Card>\n\n {/* Count + Search */}\n <Card borderBottom paddingX={3} paddingY={0}>\n <Flex align=\"stretch\" style={{ minHeight: 33 }}>\n <Flex align=\"center\" paddingY={2} style={{ flexShrink: 0 }}>\n <Text size={1} muted>\n {filteredDocuments.length} of {documents.length} document{documents.length !== 1 ? 's' : ''}\n </Text>\n </Flex>\n <Box style={{ flex: 1, minWidth: 180, borderLeft: '1px solid var(--card-border-color)', paddingLeft: 4, marginLeft: 12, display: 'flex', alignItems: 'center' }}>\n <TextInput\n icon={SearchIcon}\n placeholder=\"Search...\"\n value={searchQuery}\n onChange={e => setSearchQuery(e.currentTarget.value)}\n fontSize={1}\n padding={2}\n border={false}\n style={{ boxShadow: 'none', width: '100%' }}\n />\n </Box>\n </Flex>\n </Card>\n\n {/* Document list */}\n <Box style={{ flex: 1, overflow: 'auto' }}>\n <Stack space={1} padding={2}>\n {filteredDocuments.length === 0 ? (\n <Card padding={4}><Text muted align=\"center\">No documents match your filters</Text></Card>\n ) : (\n filteredDocuments.map((doc: ReferencingDocument) => {\n const schemaType = getSchemaType(doc._type)\n return (\n <Card\n key={doc._id}\n as=\"button\"\n padding={2}\n radius={2}\n onClick={() => router.navigateIntent('edit', { id: doc._id, type: doc._type })}\n style={{ cursor: 'pointer', textAlign: 'left', width: '100%' }}\n >\n <Flex gap={3} align=\"center\">\n <Box flex={1}>\n {schemaType ? (\n <Preview schemaType={schemaType} value={doc} layout=\"default\" />\n ) : (\n <Text weight=\"medium\">{doc.title}</Text>\n )}\n </Box>\n <Badge tone=\"primary\" fontSize={0}>{schemaType?.title || doc._type}</Badge>\n </Flex>\n </Card>\n )\n })\n )}\n </Stack>\n </Box>\n </Stack>\n )\n}\n","import { definePlugin } from 'sanity'\nimport type { StructureBuilder } from 'sanity/structure'\nimport { LinkIcon } from '@sanity/icons'\nimport type { ComponentType } from 'react'\nimport { ReferencesBadge } from './badge'\nimport { ReferencesPane } from './pane'\n\ninterface ReferencesConfig {\n /**\n * Document types to exclude from showing the References badge\n * @example ['media.tag', 'sanity.imageAsset']\n */\n exclude?: string[]\n}\n\n/**\n * Plugin that shows documents referencing the current document.\n * Displays a badge with the reference count.\n *\n * To add the References tab to your documents, use the Structure Builder API\n * with the exported `ReferencesPane` component.\n *\n * @example Basic usage (badge only)\n * ```ts\n * // sanity.config.ts\n * import { references } from 'sanity-plugin-references'\n *\n * export default defineConfig({\n * plugins: [references()],\n * })\n * ```\n *\n * @example Exclude specific document types from showing the badge\n * ```ts\n * references({ exclude: ['media.tag', 'sanity.imageAsset'] })\n * ```\n *\n * @example Adding the References tab with Structure Builder\n * ```ts\n * // sanity.config.ts\n * import { structureTool } from 'sanity/structure'\n * import { references, ReferencesPane } from 'sanity-plugin-references'\n * import { LinkIcon } from '@sanity/icons'\n *\n * export default defineConfig({\n * plugins: [\n * references(),\n * structureTool({\n * defaultDocumentNode: (S, { schemaType }) => {\n * return S.document().views([\n * S.view.form(),\n * S.view.component(ReferencesPane).title('References').icon(LinkIcon),\n * ])\n * },\n * }),\n * ],\n * })\n * ```\n *\n * @example Using the referencesView helper\n * ```ts\n * import { references, referencesView } from 'sanity-plugin-references'\n *\n * structureTool({\n * defaultDocumentNode: (S) => S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ]),\n * })\n * ```\n */\nexport const references = definePlugin<ReferencesConfig | void>((config) => {\n const excludeTypes = config?.exclude || []\n\n return {\n name: 'references',\n document: {\n badges: (prev, context) => {\n // Don't show badge for excluded types\n if (excludeTypes.includes(context.schemaType)) {\n return prev\n }\n return [...prev, ReferencesBadge]\n },\n },\n }\n})\n\nexport type { ReferencesConfig }\n\n\n/**\n * Helper to create a References view for Structure Builder.\n *\n * @example\n * ```ts\n * S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ])\n * ```\n *\n * @example With custom title and icon\n * ```ts\n * referencesView(S, { title: 'Incoming Links', icon: MyIcon })\n * ```\n */\nexport function referencesView(S: StructureBuilder, options?: {\n title?: string\n icon?: ComponentType\n}) {\n return S.view\n .component(ReferencesPane)\n .title(options?.title || 'References')\n .icon(options?.icon || LinkIcon)\n}\n\nexport { ReferencesPane } from './pane'\nexport { ReferencesBadge } from './badge'\nexport { LinkIcon as ReferencesIcon } from '@sanity/icons'\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sanity-plugin-references",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "See which documents reference the current document in Sanity Studio",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sanity",
|
|
7
|
+
"sanity-plugin",
|
|
8
|
+
"references",
|
|
9
|
+
"incoming-references",
|
|
10
|
+
"document-references"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/flayks/sanity-plugin-references#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/flayks/sanity-plugin-references/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+ssh://git@github.com/flayks/sanity-plugin-references.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Félix Péault (Flayks) <hello@flayks.com>",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"source": "./src/index.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"sanity.json",
|
|
37
|
+
"src",
|
|
38
|
+
"v2-incompatible.js"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
|
|
42
|
+
"format": "prettier --write --cache --ignore-unknown .",
|
|
43
|
+
"link-watch": "plugin-kit link-watch",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"prepublishOnly": "npm run build",
|
|
46
|
+
"watch": "pkg-utils watch --strict"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@sanity/icons": "^3.7.4",
|
|
50
|
+
"@sanity/incompatible-plugin": "^1.0.5",
|
|
51
|
+
"@sanity/ui": "^3.1.11",
|
|
52
|
+
"@types/react-dom": "^19.2.3",
|
|
53
|
+
"react-icons": "^5.5.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@sanity/pkg-utils": "^10.2.5",
|
|
57
|
+
"@sanity/plugin-kit": "^4.0.20",
|
|
58
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
59
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
60
|
+
"@semantic-release/git": "^10.0.1",
|
|
61
|
+
"@semantic-release/github": "^12.0.2",
|
|
62
|
+
"@semantic-release/npm": "^13.1.3",
|
|
63
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
64
|
+
"@types/react": "^19.2.7",
|
|
65
|
+
"@typescript-eslint/eslint-plugin": "^8",
|
|
66
|
+
"@typescript-eslint/parser": "^8",
|
|
67
|
+
"eslint": "^9",
|
|
68
|
+
"eslint-config-prettier": "^10",
|
|
69
|
+
"eslint-config-sanity": "^7.1.4",
|
|
70
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
71
|
+
"eslint-plugin-react": "^7.37.5",
|
|
72
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
73
|
+
"prettier": "^3.7.4",
|
|
74
|
+
"prettier-plugin-packagejson": "^2.5.20",
|
|
75
|
+
"react": "^19.2.3",
|
|
76
|
+
"react-dom": "^19.2.3",
|
|
77
|
+
"sanity": "^5.1.0",
|
|
78
|
+
"semantic-release": "^25.0.2",
|
|
79
|
+
"styled-components": "^6.1.19",
|
|
80
|
+
"typescript": "^5.9"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"react": "^18 || ^19",
|
|
84
|
+
"react-dom": "^18 || ^19",
|
|
85
|
+
"sanity": "^3 || ^4 || ^5"
|
|
86
|
+
},
|
|
87
|
+
"engines": {
|
|
88
|
+
"node": ">=18"
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"exports": {
|
|
92
|
+
".": {
|
|
93
|
+
"import": "./dist/index.js",
|
|
94
|
+
"default": "./dist/index.js"
|
|
95
|
+
},
|
|
96
|
+
"./package.json": "./package.json"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"browserslist": "extends @sanity/browserslist-config",
|
|
100
|
+
"sanityPlugin": {
|
|
101
|
+
"verifyPackage": {
|
|
102
|
+
"eslintImports": false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/sanity.json
ADDED
package/src/badge.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { type DocumentBadgeComponent, useClient } from 'sanity'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Badge component that displays the count of documents referencing the current document
|
|
6
|
+
*/
|
|
7
|
+
export const ReferencesBadge: DocumentBadgeComponent = (props) => {
|
|
8
|
+
const { id, published, draft } = props
|
|
9
|
+
const documentId = published?._id || draft?._id || id
|
|
10
|
+
const [count, setCount] = useState<number | null>(null)
|
|
11
|
+
|
|
12
|
+
const client = useClient({ apiVersion: '2026-01-05' })
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!documentId) return
|
|
16
|
+
|
|
17
|
+
const cleanId = documentId.replace(/^drafts\./, '')
|
|
18
|
+
|
|
19
|
+
// Get count of documents referencing this document
|
|
20
|
+
client.fetch<number>(
|
|
21
|
+
/* groq */`count(*[references($id) && !(_id in path("drafts.**"))])`,
|
|
22
|
+
{ id: cleanId }
|
|
23
|
+
).then(setCount)
|
|
24
|
+
|
|
25
|
+
// Subscribe to changes
|
|
26
|
+
const subscription = client.listen(
|
|
27
|
+
/* groq */`*[references($id)]`,
|
|
28
|
+
{ id: cleanId }
|
|
29
|
+
).subscribe(() => {
|
|
30
|
+
client.fetch<number>(
|
|
31
|
+
/* groq */`count(*[references($id) && !(_id in path("drafts.**"))])`,
|
|
32
|
+
{ id: cleanId }
|
|
33
|
+
).then(setCount)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return () => subscription.unsubscribe()
|
|
37
|
+
}, [documentId, client])
|
|
38
|
+
|
|
39
|
+
// Don't show badge if no references or still loading
|
|
40
|
+
if (count === null || count === 0) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
label: `${count} reference${count > 1 ? 's' : ''}`,
|
|
46
|
+
title: `${count} document${count > 1 ? 's' : ''} reference this. See "References" tab.`,
|
|
47
|
+
color: 'primary',
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { definePlugin } from 'sanity'
|
|
2
|
+
import type { StructureBuilder } from 'sanity/structure'
|
|
3
|
+
import { LinkIcon } from '@sanity/icons'
|
|
4
|
+
import type { ComponentType } from 'react'
|
|
5
|
+
import { ReferencesBadge } from './badge'
|
|
6
|
+
import { ReferencesPane } from './pane'
|
|
7
|
+
|
|
8
|
+
interface ReferencesConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Document types to exclude from showing the References badge
|
|
11
|
+
* @example ['media.tag', 'sanity.imageAsset']
|
|
12
|
+
*/
|
|
13
|
+
exclude?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plugin that shows documents referencing the current document.
|
|
18
|
+
* Displays a badge with the reference count.
|
|
19
|
+
*
|
|
20
|
+
* To add the References tab to your documents, use the Structure Builder API
|
|
21
|
+
* with the exported `ReferencesPane` component.
|
|
22
|
+
*
|
|
23
|
+
* @example Basic usage (badge only)
|
|
24
|
+
* ```ts
|
|
25
|
+
* // sanity.config.ts
|
|
26
|
+
* import { references } from 'sanity-plugin-references'
|
|
27
|
+
*
|
|
28
|
+
* export default defineConfig({
|
|
29
|
+
* plugins: [references()],
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example Exclude specific document types from showing the badge
|
|
34
|
+
* ```ts
|
|
35
|
+
* references({ exclude: ['media.tag', 'sanity.imageAsset'] })
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example Adding the References tab with Structure Builder
|
|
39
|
+
* ```ts
|
|
40
|
+
* // sanity.config.ts
|
|
41
|
+
* import { structureTool } from 'sanity/structure'
|
|
42
|
+
* import { references, ReferencesPane } from 'sanity-plugin-references'
|
|
43
|
+
* import { LinkIcon } from '@sanity/icons'
|
|
44
|
+
*
|
|
45
|
+
* export default defineConfig({
|
|
46
|
+
* plugins: [
|
|
47
|
+
* references(),
|
|
48
|
+
* structureTool({
|
|
49
|
+
* defaultDocumentNode: (S, { schemaType }) => {
|
|
50
|
+
* return S.document().views([
|
|
51
|
+
* S.view.form(),
|
|
52
|
+
* S.view.component(ReferencesPane).title('References').icon(LinkIcon),
|
|
53
|
+
* ])
|
|
54
|
+
* },
|
|
55
|
+
* }),
|
|
56
|
+
* ],
|
|
57
|
+
* })
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example Using the referencesView helper
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { references, referencesView } from 'sanity-plugin-references'
|
|
63
|
+
*
|
|
64
|
+
* structureTool({
|
|
65
|
+
* defaultDocumentNode: (S) => S.document().views([
|
|
66
|
+
* S.view.form(),
|
|
67
|
+
* referencesView(S),
|
|
68
|
+
* ]),
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const references = definePlugin<ReferencesConfig | void>((config) => {
|
|
73
|
+
const excludeTypes = config?.exclude || []
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
name: 'references',
|
|
77
|
+
document: {
|
|
78
|
+
badges: (prev, context) => {
|
|
79
|
+
// Don't show badge for excluded types
|
|
80
|
+
if (excludeTypes.includes(context.schemaType)) {
|
|
81
|
+
return prev
|
|
82
|
+
}
|
|
83
|
+
return [...prev, ReferencesBadge]
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
export type { ReferencesConfig }
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Helper to create a References view for Structure Builder.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* S.document().views([
|
|
98
|
+
* S.view.form(),
|
|
99
|
+
* referencesView(S),
|
|
100
|
+
* ])
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* @example With custom title and icon
|
|
104
|
+
* ```ts
|
|
105
|
+
* referencesView(S, { title: 'Incoming Links', icon: MyIcon })
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function referencesView(S: StructureBuilder, options?: {
|
|
109
|
+
title?: string
|
|
110
|
+
icon?: ComponentType
|
|
111
|
+
}) {
|
|
112
|
+
return S.view
|
|
113
|
+
.component(ReferencesPane)
|
|
114
|
+
.title(options?.title || 'References')
|
|
115
|
+
.icon(options?.icon || LinkIcon)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { ReferencesPane } from './pane'
|
|
119
|
+
export { ReferencesBadge } from './badge'
|
|
120
|
+
export { LinkIcon as ReferencesIcon } from '@sanity/icons'
|
package/src/pane.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { useClient, useSchema, Preview } from 'sanity'
|
|
3
|
+
import { type UserViewComponent } from 'sanity/structure'
|
|
4
|
+
import { useRouter } from 'sanity/router'
|
|
5
|
+
import { Box, Card, Flex, Inline, Stack, Text, TextInput, Select, Spinner, Button, Badge, Radio } from '@sanity/ui'
|
|
6
|
+
import { SearchIcon, SortIcon, LinkIcon } from '@sanity/icons'
|
|
7
|
+
|
|
8
|
+
interface ReferencingDocument {
|
|
9
|
+
_id: string
|
|
10
|
+
_type: string
|
|
11
|
+
_updatedAt: string
|
|
12
|
+
title?: string
|
|
13
|
+
}
|
|
14
|
+
type SortOption = 'updatedAt' | 'type' | 'title'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Structure Builder view component that displays documents referencing the current document.
|
|
18
|
+
* Use this component with S.view.component() in your structure configuration.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { references, referencesView } from 'sanity-plugin-references'
|
|
23
|
+
* import { LinkIcon } from '@sanity/icons'
|
|
24
|
+
*
|
|
25
|
+
* S.document().views([
|
|
26
|
+
* S.view.form(),
|
|
27
|
+
* referencesView(S),
|
|
28
|
+
* ])
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const ReferencesPane: UserViewComponent = (props) => {
|
|
32
|
+
const { documentId } = props
|
|
33
|
+
const client = useClient({ apiVersion: '2026-01-05' })
|
|
34
|
+
const schema = useSchema()
|
|
35
|
+
const router = useRouter()
|
|
36
|
+
|
|
37
|
+
const [documents, setDocuments] = useState<ReferencingDocument[]>([])
|
|
38
|
+
const [loading, setLoading] = useState(true)
|
|
39
|
+
const [error, setError] = useState<string | null>(null)
|
|
40
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
41
|
+
const [typeFilter, setTypeFilter] = useState('all')
|
|
42
|
+
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
|
43
|
+
const [sortDesc, setSortDesc] = useState(true)
|
|
44
|
+
|
|
45
|
+
const cleanId = useMemo(() => documentId.replace(/^drafts\./, ''), [documentId])
|
|
46
|
+
|
|
47
|
+
const fetchDocuments = useCallback(async () => {
|
|
48
|
+
if (!cleanId) return
|
|
49
|
+
setLoading(true)
|
|
50
|
+
setError(null)
|
|
51
|
+
try {
|
|
52
|
+
const results = await client.fetch<ReferencingDocument[]>(
|
|
53
|
+
`*[references($id) && !(_id in path("drafts.**"))] {
|
|
54
|
+
_id, _type, _updatedAt, "title": coalesce(title, name, "Untitled")
|
|
55
|
+
} | order(_updatedAt desc)`,
|
|
56
|
+
{ id: cleanId }
|
|
57
|
+
)
|
|
58
|
+
setDocuments(results)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch references')
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false)
|
|
63
|
+
}
|
|
64
|
+
}, [client, cleanId])
|
|
65
|
+
|
|
66
|
+
useEffect(() => { fetchDocuments() }, [fetchDocuments])
|
|
67
|
+
|
|
68
|
+
const documentTypes = useMemo(() =>
|
|
69
|
+
[...new Set(documents.map((doc: ReferencingDocument) => doc._type))].sort(),
|
|
70
|
+
[documents]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const filteredDocuments = useMemo(() => {
|
|
74
|
+
const query = searchQuery.toLowerCase().trim()
|
|
75
|
+
return documents
|
|
76
|
+
.filter((doc: ReferencingDocument) => typeFilter === 'all' || doc._type === typeFilter)
|
|
77
|
+
.filter((doc: ReferencingDocument) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query))
|
|
78
|
+
.sort((a: ReferencingDocument, b: ReferencingDocument) => {
|
|
79
|
+
const cmp = sortBy === 'updatedAt'
|
|
80
|
+
? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime()
|
|
81
|
+
: sortBy === 'type'
|
|
82
|
+
? a._type.localeCompare(b._type)
|
|
83
|
+
: (a.title || '').localeCompare(b.title || '')
|
|
84
|
+
return sortDesc ? -cmp : cmp
|
|
85
|
+
})
|
|
86
|
+
}, [documents, typeFilter, searchQuery, sortBy, sortDesc])
|
|
87
|
+
|
|
88
|
+
const getSchemaType = useCallback((type: string) => schema.get(type), [schema])
|
|
89
|
+
|
|
90
|
+
if (loading) {
|
|
91
|
+
return <Flex align="center" justify="center" padding={5}><Spinner muted /></Flex>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (error) {
|
|
95
|
+
return <Card padding={4} tone="critical"><Text>{error}</Text></Card>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (documents.length === 0) {
|
|
99
|
+
return (
|
|
100
|
+
<Card padding={5}>
|
|
101
|
+
<Flex align="center" justify="center" direction="column" gap={3}>
|
|
102
|
+
<LinkIcon style={{ fontSize: 32, opacity: 0.4 }} />
|
|
103
|
+
<Text muted>No document reference this one</Text>
|
|
104
|
+
</Flex>
|
|
105
|
+
</Card>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const useSelectForFilters = documentTypes.length > 5
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Stack space={0} style={{ height: '100%' }}>
|
|
113
|
+
{/* Toolbar */}
|
|
114
|
+
<Card borderBottom padding={2}>
|
|
115
|
+
<Flex gap={2} align="center" wrap="nowrap">
|
|
116
|
+
{useSelectForFilters ? (
|
|
117
|
+
<Select
|
|
118
|
+
value={typeFilter}
|
|
119
|
+
onChange={e => setTypeFilter(e.currentTarget.value)}
|
|
120
|
+
fontSize={1}
|
|
121
|
+
padding={2}
|
|
122
|
+
style={{ minWidth: 150, flexShrink: 0 }}
|
|
123
|
+
>
|
|
124
|
+
<option value="all">All types</option>
|
|
125
|
+
{documentTypes.map((type: string) => {
|
|
126
|
+
const schemaType = getSchemaType(type)
|
|
127
|
+
return (
|
|
128
|
+
<option key={type} value={type}>
|
|
129
|
+
{schemaType?.title || type}
|
|
130
|
+
</option>
|
|
131
|
+
)
|
|
132
|
+
})}
|
|
133
|
+
</Select>
|
|
134
|
+
) : (
|
|
135
|
+
<Inline space={2} style={{ flexShrink: 0 }}>
|
|
136
|
+
<Flex align="center" gap={1} as="label" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
|
137
|
+
<Radio checked={typeFilter === 'all'} onChange={() => setTypeFilter('all')} />
|
|
138
|
+
<Text size={1} weight={typeFilter === 'all' ? 'semibold' : 'regular'}>All</Text>
|
|
139
|
+
</Flex>
|
|
140
|
+
{documentTypes.map((type: string) => {
|
|
141
|
+
const schemaType = getSchemaType(type)
|
|
142
|
+
return (
|
|
143
|
+
<Flex key={type} align="center" gap={1} as="label" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
|
144
|
+
<Radio checked={typeFilter === type} onChange={() => setTypeFilter(type)} />
|
|
145
|
+
<Text size={1} weight={typeFilter === type ? 'semibold' : 'regular'}>
|
|
146
|
+
{schemaType?.title || type}
|
|
147
|
+
</Text>
|
|
148
|
+
</Flex>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</Inline>
|
|
152
|
+
)}
|
|
153
|
+
<Box flex={1} />
|
|
154
|
+
<Flex gap={1} style={{ flexShrink: 0 }}>
|
|
155
|
+
<Select value={sortBy} onChange={e => setSortBy(e.currentTarget.value as SortOption)} fontSize={1} padding={2}>
|
|
156
|
+
<option value="updatedAt">Updated</option>
|
|
157
|
+
<option value="type">Type</option>
|
|
158
|
+
<option value="title">Title</option>
|
|
159
|
+
</Select>
|
|
160
|
+
<Button
|
|
161
|
+
icon={SortIcon}
|
|
162
|
+
mode="ghost"
|
|
163
|
+
onClick={() => setSortDesc((d: boolean) => !d)}
|
|
164
|
+
title={sortDesc ? 'Descending' : 'Ascending'}
|
|
165
|
+
fontSize={1}
|
|
166
|
+
padding={2}
|
|
167
|
+
/>
|
|
168
|
+
</Flex>
|
|
169
|
+
</Flex>
|
|
170
|
+
</Card>
|
|
171
|
+
|
|
172
|
+
{/* Count + Search */}
|
|
173
|
+
<Card borderBottom paddingX={3} paddingY={0}>
|
|
174
|
+
<Flex align="stretch" style={{ minHeight: 33 }}>
|
|
175
|
+
<Flex align="center" paddingY={2} style={{ flexShrink: 0 }}>
|
|
176
|
+
<Text size={1} muted>
|
|
177
|
+
{filteredDocuments.length} of {documents.length} document{documents.length !== 1 ? 's' : ''}
|
|
178
|
+
</Text>
|
|
179
|
+
</Flex>
|
|
180
|
+
<Box style={{ flex: 1, minWidth: 180, borderLeft: '1px solid var(--card-border-color)', paddingLeft: 4, marginLeft: 12, display: 'flex', alignItems: 'center' }}>
|
|
181
|
+
<TextInput
|
|
182
|
+
icon={SearchIcon}
|
|
183
|
+
placeholder="Search..."
|
|
184
|
+
value={searchQuery}
|
|
185
|
+
onChange={e => setSearchQuery(e.currentTarget.value)}
|
|
186
|
+
fontSize={1}
|
|
187
|
+
padding={2}
|
|
188
|
+
border={false}
|
|
189
|
+
style={{ boxShadow: 'none', width: '100%' }}
|
|
190
|
+
/>
|
|
191
|
+
</Box>
|
|
192
|
+
</Flex>
|
|
193
|
+
</Card>
|
|
194
|
+
|
|
195
|
+
{/* Document list */}
|
|
196
|
+
<Box style={{ flex: 1, overflow: 'auto' }}>
|
|
197
|
+
<Stack space={1} padding={2}>
|
|
198
|
+
{filteredDocuments.length === 0 ? (
|
|
199
|
+
<Card padding={4}><Text muted align="center">No documents match your filters</Text></Card>
|
|
200
|
+
) : (
|
|
201
|
+
filteredDocuments.map((doc: ReferencingDocument) => {
|
|
202
|
+
const schemaType = getSchemaType(doc._type)
|
|
203
|
+
return (
|
|
204
|
+
<Card
|
|
205
|
+
key={doc._id}
|
|
206
|
+
as="button"
|
|
207
|
+
padding={2}
|
|
208
|
+
radius={2}
|
|
209
|
+
onClick={() => router.navigateIntent('edit', { id: doc._id, type: doc._type })}
|
|
210
|
+
style={{ cursor: 'pointer', textAlign: 'left', width: '100%' }}
|
|
211
|
+
>
|
|
212
|
+
<Flex gap={3} align="center">
|
|
213
|
+
<Box flex={1}>
|
|
214
|
+
{schemaType ? (
|
|
215
|
+
<Preview schemaType={schemaType} value={doc} layout="default" />
|
|
216
|
+
) : (
|
|
217
|
+
<Text weight="medium">{doc.title}</Text>
|
|
218
|
+
)}
|
|
219
|
+
</Box>
|
|
220
|
+
<Badge tone="primary" fontSize={0}>{schemaType?.title || doc._type}</Badge>
|
|
221
|
+
</Flex>
|
|
222
|
+
</Card>
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
)}
|
|
226
|
+
</Stack>
|
|
227
|
+
</Box>
|
|
228
|
+
</Stack>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
|
|
2
|
+
const {name, version, sanityExchangeUrl} = require('./package.json')
|
|
3
|
+
|
|
4
|
+
export default showIncompatiblePluginDialog({
|
|
5
|
+
name: name,
|
|
6
|
+
versions: {
|
|
7
|
+
v3: version,
|
|
8
|
+
v2: undefined,
|
|
9
|
+
},
|
|
10
|
+
sanityExchangeUrl,
|
|
11
|
+
})
|