@sanity/hierarchical-document-list 0.1.0-next.1
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 +210 -0
- package/lib/TreeDeskStructure.d.ts +7 -0
- package/lib/TreeDeskStructure.js +43 -0
- package/lib/TreeInputComponent.d.ts +19 -0
- package/lib/TreeInputComponent.js +10 -0
- package/lib/components/DocumentInNode.d.ts +11 -0
- package/lib/components/DocumentInNode.js +46 -0
- package/lib/components/DocumentPreviewStatus.d.ts +7 -0
- package/lib/components/DocumentPreviewStatus.js +16 -0
- package/lib/components/NodeActions.d.ts +10 -0
- package/lib/components/NodeActions.js +23 -0
- package/lib/components/NodeContentRenderer.d.ts +8 -0
- package/lib/components/NodeContentRenderer.js +79 -0
- package/lib/components/PlaceholderDropzone.d.ts +9 -0
- package/lib/components/PlaceholderDropzone.js +17 -0
- package/lib/components/TreeEditor.d.ts +12 -0
- package/lib/components/TreeEditor.js +41 -0
- package/lib/components/TreeEditorErrorBoundary.d.ts +17 -0
- package/lib/components/TreeEditorErrorBoundary.js +40 -0
- package/lib/components/TreeNodeRenderer.d.ts +3 -0
- package/lib/components/TreeNodeRenderer.js +22 -0
- package/lib/components/TreeNodeRendererScaffold.d.ts +4 -0
- package/lib/components/TreeNodeRendererScaffold.js +164 -0
- package/lib/createDeskHierarchy.d.ts +10 -0
- package/lib/createDeskHierarchy.js +52 -0
- package/lib/createHierarchicalField.d.ts +8 -0
- package/lib/createHierarchicalField.js +30 -0
- package/lib/hiearchy.tree.d.ts +23 -0
- package/lib/hiearchy.tree.js +28 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/utils/flatDataToTree.d.ts +6 -0
- package/lib/utils/flatDataToTree.js +14 -0
- package/lib/utils/getAdjescentNodes.d.ts +12 -0
- package/lib/utils/getAdjescentNodes.js +15 -0
- package/lib/utils/getCommonTreeProps.d.ts +7 -0
- package/lib/utils/getCommonTreeProps.js +15 -0
- package/lib/utils/getTreeHeight.d.ts +3 -0
- package/lib/utils/getTreeHeight.js +7 -0
- package/lib/utils/gradientPatchAdapter.d.ts +4 -0
- package/lib/utils/gradientPatchAdapter.js +34 -0
- package/lib/utils/idUtils.d.ts +2 -0
- package/lib/utils/idUtils.js +6 -0
- package/lib/utils/moveItemInArray.d.ts +5 -0
- package/lib/utils/moveItemInArray.js +13 -0
- package/lib/utils/treeData.d.ts +18 -0
- package/lib/utils/treeData.js +77 -0
- package/lib/utils/treePatches.d.ts +13 -0
- package/lib/utils/treePatches.js +133 -0
- package/lib/utils/useAllItems.d.ts +7 -0
- package/lib/utils/useAllItems.js +92 -0
- package/lib/utils/useLocalTree.d.ts +17 -0
- package/lib/utils/useLocalTree.js +27 -0
- package/lib/utils/useTreeOperations.d.ts +9 -0
- package/lib/utils/useTreeOperations.js +16 -0
- package/lib/utils/useTreeOperationsProvider.d.ts +15 -0
- package/lib/utils/useTreeOperationsProvider.js +52 -0
- package/package.json +54 -0
- package/sanity.json +12 -0
- package/screenshot-1.jpg +0 -0
- package/tsconfig.json +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Sanity.io
|
|
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,210 @@
|
|
|
1
|
+
# sanity-plugin-hierarchical-document-list
|
|
2
|
+
|
|
3
|
+
Plugin for editing hierarchical references in the [Sanity studio](https://www.sanity.io/docs/sanity-studio).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# From the root of your sanity project
|
|
11
|
+
sanity install @sanity/hierarchical-document-list
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With the plugin installed, you'll add the following to your Desk Structure:
|
|
15
|
+
|
|
16
|
+
💡 _If you don't have a custom desk structure, refer to the [Structure Builder docs](https://www.sanity.io/docs/overview-structure-builder) to learn how to do so._
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
// deskStructure.js
|
|
20
|
+
import S from '@sanity/desk-tool/structure-builder'
|
|
21
|
+
import {createDeskHierarchy} from '@sanity/hierarchical-document-list'
|
|
22
|
+
|
|
23
|
+
export default () => {
|
|
24
|
+
return S.list()
|
|
25
|
+
.title('Content')
|
|
26
|
+
.items([
|
|
27
|
+
...S.documentTypeListItems(), // or whatever other structure you have
|
|
28
|
+
createDeskHierarchy({
|
|
29
|
+
title: 'Main table of contents',
|
|
30
|
+
|
|
31
|
+
// The hiearchy will be stored in this document ID 👇
|
|
32
|
+
documentId: 'main-table-of-contents',
|
|
33
|
+
|
|
34
|
+
// Document types editors should be able to include in the hierarchy
|
|
35
|
+
referenceTo: ['site.page', 'site.post', 'docs.article', 'social.youtubeVideo'],
|
|
36
|
+
|
|
37
|
+
// ❓ Optional: provide filters and/or parameters for narrowing which documents can be added
|
|
38
|
+
referenceOptions: {
|
|
39
|
+
filter: 'status in $acceptedStatuses',
|
|
40
|
+
filterParams: {
|
|
41
|
+
acceptedStatuses: ['published', 'approved']
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
])
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
The hierarchical data is stored in the document specified by the `documentId` of your choosing. As compared to storing `parent` fields in each individual document in the hierarchy, this makes it easier to implement different hierarchies for the same content according to the context, and also simplifies querying the full structure - as you'll see in [Querying data](#querying-data) below.
|
|
52
|
+
|
|
53
|
+
Keep in mind that **this document is live-edited**, meaning it has no draft and every change by editors will directly affect its published version.
|
|
54
|
+
|
|
55
|
+
Instead of manually adding items one-by-one, the plugin will create a [GROQ](https://www.sanity.io/docs/overview-groq) query that matches all documents with a `_type` in `referenceTo`, that also match the optional `referenceOptions.filter`. From these documents, editors are able to drag, nest and re-order them at will from the "Items not added" list.
|
|
56
|
+
|
|
57
|
+
If a document in the tree doesn't match the filters set, it'll still exist in the tree. This can happen if the document has a new, unfitting value, the configuration changed or it was deleted. Although the tree will still be publishable, editors will get a warning and won't be able to drag these entries around.
|
|
58
|
+
|
|
59
|
+
## Querying data
|
|
60
|
+
|
|
61
|
+
The plugin stores flat arrays which represent your hierarchical data through `parent` keys. Here's an example of one top-level item with one child:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
[
|
|
65
|
+
{
|
|
66
|
+
"_key": "741b9edde2ba",
|
|
67
|
+
"_type": "hierarchy.node",
|
|
68
|
+
"node": {
|
|
69
|
+
"_ref": "75c47994-e6bb-487a-b8c9-b283f2436031",
|
|
70
|
+
"_type": "reference",
|
|
71
|
+
"_weak": true // This plugin includes weak references by default
|
|
72
|
+
}
|
|
73
|
+
// no `parent`, this item is top-level
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"_key": "f92eaeec96f7",
|
|
77
|
+
"_type": "hierarchy.node",
|
|
78
|
+
"node": {
|
|
79
|
+
"_ref": "7ad60a02-5d6e-47d8-92e2-6724cc130058",
|
|
80
|
+
"_type": "reference",
|
|
81
|
+
"_weak": true
|
|
82
|
+
},
|
|
83
|
+
// The `parent` property points to the _key of the parent node where this one is nested
|
|
84
|
+
"parent": "741b9edde2ba"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
📌 If using GraphQL, refer to [Usage with GraphQL](#usage-with-graphql).
|
|
90
|
+
|
|
91
|
+
From the the above, we know how to expand referenced documents in GROQ:
|
|
92
|
+
|
|
93
|
+
```groq
|
|
94
|
+
*[_id == "main-table-of-contents"][0]{
|
|
95
|
+
tree[] {
|
|
96
|
+
// Make sure you include each item's _key and parent
|
|
97
|
+
_key,
|
|
98
|
+
parent,
|
|
99
|
+
|
|
100
|
+
// "Expand" the reference to the node
|
|
101
|
+
node->{
|
|
102
|
+
// Get whatever property you need from your documents
|
|
103
|
+
title,
|
|
104
|
+
slug,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The query above will then need to be converted from flat data to a tree. Refer to [Using the data](#using-the-data).
|
|
111
|
+
|
|
112
|
+
<!-- ### Other query scenarios
|
|
113
|
+
|
|
114
|
+
Find a given document in a hierarchy and get its parent - useful for rendering breadcrumbs:
|
|
115
|
+
|
|
116
|
+
```groq
|
|
117
|
+
// Works starting from Content Lake V2021-03-25
|
|
118
|
+
*[_id == "main-table-of-contents"][0]{
|
|
119
|
+
// From the tree, get the 1st node that references a given document _id
|
|
120
|
+
tree[node._ref == "my-book-section"][0] {
|
|
121
|
+
_key,
|
|
122
|
+
"section": node->{
|
|
123
|
+
title,
|
|
124
|
+
},
|
|
125
|
+
// Then, from the tree get the element matching the `parent` _key of the found node
|
|
126
|
+
"parentChapter": ^.tree[_key == ^.parent][0]{
|
|
127
|
+
_key,
|
|
128
|
+
"chapter": node->{
|
|
129
|
+
title,
|
|
130
|
+
contributors,
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---- -->
|
|
138
|
+
|
|
139
|
+
## Using the data
|
|
140
|
+
|
|
141
|
+
From the flat data queried, you'll need to convert it to a nested tree with `flatDataToTree`:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
import {flatDataToTree} from '@sanity/hierarchical-document-list'
|
|
145
|
+
|
|
146
|
+
const hierarchyDocument = await client.fetch(`*[_id == "book-v3-review-a"][0]{
|
|
147
|
+
tree[] {
|
|
148
|
+
// Make sure you include each item's _key and parent
|
|
149
|
+
_key,
|
|
150
|
+
parent,
|
|
151
|
+
node->{
|
|
152
|
+
title,
|
|
153
|
+
slug,
|
|
154
|
+
content,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}`)
|
|
158
|
+
const tree = flatDataToTree(data.tree)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
After the transformation above, nodes with nested entries will include a `children` array. This data structure is recursive.
|
|
162
|
+
|
|
163
|
+
## Usage with GraphQL
|
|
164
|
+
|
|
165
|
+
By default, this plugin will create and update documents of `_type: hierarchy.tree`, with a `tree` field holding the hierarchical data. When deploying a [GraphQL Sanity endpoint](https://www.sanity.io/docs/graphql), however, you'll need an explicit document type in your schema so that you get the proper types for querying.
|
|
166
|
+
|
|
167
|
+
To add this document type, create a new document schema similar to the following:
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import {createHierarchicalField} from '@sanity/hierarchical-document-list'
|
|
171
|
+
|
|
172
|
+
export default {
|
|
173
|
+
name: 'myCustomHierarchicalType',
|
|
174
|
+
title: 'Custom document type for holding hierarchical data',
|
|
175
|
+
type: 'document',
|
|
176
|
+
fields: [
|
|
177
|
+
createHierarchicalField({
|
|
178
|
+
name: 'treeData', // custom key for
|
|
179
|
+
title: 'Custom tree',
|
|
180
|
+
options: {
|
|
181
|
+
referenceTo: ['category']
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
📌 **Note:** you can also use the method above to add hierarchies inside the schema of documents and objects. We're considering adapting this input to support any type of nest-able data, not only references. Until then, avoid `createHierarchicalField` for fields in nested schemas as, in these contexts, it lacks the necessary affordances for a good editing experience.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
Then, in your desk structure where you added the hierarchical document(s), include the right `documentType` and `fieldKeyInDocument` properties:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
createDeskHierarchy({
|
|
198
|
+
title: 'Hierarchies',
|
|
199
|
+
referenceTo: ['product', 'collection'],
|
|
200
|
+
documentId: 'hierarchies',
|
|
201
|
+
|
|
202
|
+
// Include whatever values you defined in your schema
|
|
203
|
+
documentType: 'myCustomHierarchicalType',
|
|
204
|
+
fieldKeyInDocument: 'treeData'
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT-licensed. See LICENSE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PublishIcon } from '@sanity/icons';
|
|
3
|
+
import { useDocumentOperation, useEditState } from '@sanity/react-hooks';
|
|
4
|
+
import { Box, Button, Card, Container, Flex, Heading, Spinner, Stack, Text, useToast } from '@sanity/ui';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import TreeEditor from './components/TreeEditor';
|
|
7
|
+
import { toGradient } from './utils/gradientPatchAdapter';
|
|
8
|
+
const DEFAULT_TREE_FIELD_KEY = 'tree';
|
|
9
|
+
const DEFAULT_TREE_DOC_TYPE = 'hierarchy.tree';
|
|
10
|
+
const TreeDeskStructure = (props) => {
|
|
11
|
+
const treeDocType = props.options.documentType || DEFAULT_TREE_DOC_TYPE;
|
|
12
|
+
const treeFieldKey = props.options.fieldKeyInDocument || DEFAULT_TREE_FIELD_KEY;
|
|
13
|
+
const { published, draft } = useEditState(props.options.documentId, treeDocType);
|
|
14
|
+
const { patch, ...ops } = useDocumentOperation(props.options.documentId, treeDocType);
|
|
15
|
+
const { push } = useToast();
|
|
16
|
+
const treeValue = (published?.[treeFieldKey] || []);
|
|
17
|
+
const handleChange = React.useCallback((patchEvent) => {
|
|
18
|
+
if (!patch?.execute) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
patch.execute(toGradient(patchEvent.patches));
|
|
22
|
+
}, [patch]);
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
if (!published?._id && patch?.execute && !patch?.disabled) {
|
|
25
|
+
// If no published document, create it
|
|
26
|
+
patch.execute([{ setIfMissing: { [treeFieldKey]: [] } }]);
|
|
27
|
+
}
|
|
28
|
+
}, [published?._id, patch]);
|
|
29
|
+
if (draft?._id) {
|
|
30
|
+
return (_jsx(Container, { padding: 5, style: { maxWidth: '25rem' }, sizing: 'content', children: _jsx(Card, { padding: 4, border: true, radius: 2, width: 0, tone: "caution", children: _jsxs(Stack, { space: 3, children: [_jsx(Heading, { size: 1, children: "This hierarchy tree contains a draft" }, void 0), _jsx(Text, { size: 1, children: "Click on the button below to publish your draft in order to continue editing the live published document." }, void 0), _jsx(Box, { marginTop: 2, children: _jsx(Button, { fontSize: 1, tone: "positive", text: "Publish draft", icon: PublishIcon, onClick: () => {
|
|
31
|
+
ops.publish?.execute?.();
|
|
32
|
+
push({
|
|
33
|
+
status: 'info',
|
|
34
|
+
title: 'Publishing draft...'
|
|
35
|
+
});
|
|
36
|
+
} }, void 0) }, void 0)] }, void 0) }, void 0) }, void 0));
|
|
37
|
+
}
|
|
38
|
+
if (!published?._id) {
|
|
39
|
+
return (_jsx(Flex, { padding: 5, align: 'center', justify: 'center', height: 'fill', children: _jsx(Spinner, { width: 4, muted: true }, void 0) }, void 0));
|
|
40
|
+
}
|
|
41
|
+
return (_jsx(Box, { paddingBottom: 5, paddingRight: 2, children: _jsx(TreeEditor, { options: props.options, tree: treeValue, onChange: handleChange, patchPrefix: treeFieldKey }, void 0) }, void 0));
|
|
42
|
+
};
|
|
43
|
+
export default TreeDeskStructure;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FormFieldPresence } from '@sanity/base/presence';
|
|
2
|
+
import { Marker, Path } from '@sanity/types';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { SanityTreeItem, TreeFieldSchema } from './types';
|
|
5
|
+
export interface TreeInputComponentProps {
|
|
6
|
+
type: TreeFieldSchema;
|
|
7
|
+
value: SanityTreeItem[];
|
|
8
|
+
compareValue: SanityTreeItem[];
|
|
9
|
+
markers: Marker[];
|
|
10
|
+
level: number;
|
|
11
|
+
onChange: (event: unknown) => void;
|
|
12
|
+
onFocus: (path: Path) => void;
|
|
13
|
+
onBlur: () => void;
|
|
14
|
+
focusPath: Path;
|
|
15
|
+
readOnly: boolean;
|
|
16
|
+
presence: FormFieldPresence[];
|
|
17
|
+
}
|
|
18
|
+
declare const TreeInputComponent: React.FC<TreeInputComponentProps>;
|
|
19
|
+
export default TreeInputComponent;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { FormField } from '@sanity/base/components';
|
|
4
|
+
import TreeEditor from './components/TreeEditor';
|
|
5
|
+
const TreeInputComponent = React.forwardRef((props, _ref) => {
|
|
6
|
+
return (_jsx(FormField, { description: props.type.description, title: props.type.title, __unstable_markers: props.markers, __unstable_presence: props.presence,
|
|
7
|
+
// @ts-expect-error FormField's TS definitions are off - it doesn't include compareValue
|
|
8
|
+
compareValue: props.compareValue, children: _jsx(TreeEditor, { options: props.type.options, tree: props.value || [], onChange: props.onChange }, void 0) }, void 0));
|
|
9
|
+
});
|
|
10
|
+
export default TreeInputComponent;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SanityTreeItem } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Renders a preview for each referenced document.
|
|
5
|
+
* Nested inside TreeNode.tsx
|
|
6
|
+
*/
|
|
7
|
+
declare const DocumentInNode: React.FC<{
|
|
8
|
+
item: SanityTreeItem;
|
|
9
|
+
action?: React.ReactNode;
|
|
10
|
+
}>;
|
|
11
|
+
export default DocumentInNode;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { TextWithTone } from '@sanity/base/components';
|
|
3
|
+
import { usePaneRouter } from '@sanity/desk-tool';
|
|
4
|
+
import { HelpCircleIcon } from '@sanity/icons';
|
|
5
|
+
import { Box, Card, Flex, Stack, Text, Tooltip } from '@sanity/ui';
|
|
6
|
+
import Preview from 'part:@sanity/base/preview';
|
|
7
|
+
import schema from 'part:@sanity/base/schema';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import useTreeOperations from '../utils/useTreeOperations';
|
|
10
|
+
import DocumentPreviewStatus from './DocumentPreviewStatus';
|
|
11
|
+
/**
|
|
12
|
+
* Renders a preview for each referenced document.
|
|
13
|
+
* Nested inside TreeNode.tsx
|
|
14
|
+
*/
|
|
15
|
+
const DocumentInNode = (props) => {
|
|
16
|
+
const { value: { reference, docType } = {}, draftId, publishedId } = props.item;
|
|
17
|
+
const { routerPanesState, ChildLink } = usePaneRouter();
|
|
18
|
+
const { allItemsStatus } = useTreeOperations();
|
|
19
|
+
const isActive = React.useMemo(() => {
|
|
20
|
+
// If some pane is active with the current document `_id`, it's active
|
|
21
|
+
return routerPanesState.some((pane) => pane.some((group) => group.id === reference?._ref));
|
|
22
|
+
}, [routerPanesState]);
|
|
23
|
+
const schemaType = React.useMemo(() => schema.get(docType), [docType]);
|
|
24
|
+
const LinkComponent = React.useMemo(() =>
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
26
|
+
React.forwardRef((linkProps, ref) => (_jsx(ChildLink, { ...linkProps, childId: reference?._ref, ref: ref, childParameters: {
|
|
27
|
+
type: docType
|
|
28
|
+
} }, void 0))), [ChildLink, reference?._ref]);
|
|
29
|
+
if (!reference?._ref) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return (_jsxs(Flex, { gap: 2, align: "center", style: { flex: 1 }, children: [publishedId || allItemsStatus !== 'success' ? (
|
|
33
|
+
/* Card loosely copied from @sanity/desk-tool's PaneItem.tsx */
|
|
34
|
+
_jsx(Card, { __unstable_focusRing: true, as: LinkComponent, tone: isActive ? 'primary' : 'default', padding: 1, radius: 2, flex: 1, "data-as": "a", "data-ui": "PaneItem", children: _jsx(Preview, { layout: "default", type: schemaType, value: { _ref: draftId || reference?._ref }, status: _jsx(DocumentPreviewStatus, { draft: draftId
|
|
35
|
+
? {
|
|
36
|
+
_id: draftId,
|
|
37
|
+
_type: docType,
|
|
38
|
+
_updatedAt: props.item.draftUpdatedAt
|
|
39
|
+
}
|
|
40
|
+
: undefined, published: {
|
|
41
|
+
_id: reference?._ref,
|
|
42
|
+
_type: docType,
|
|
43
|
+
_updatedAt: props.item.publishedUpdatedAt
|
|
44
|
+
} }, void 0) }, void 0) }, void 0)) : (_jsx(Card, { padding: 3, radius: 1, flex: 1, children: _jsxs(Flex, { align: "center", children: [_jsx(Text, { size: 2, muted: true, style: { flex: 1 }, children: "Invalid document" }, void 0), _jsx(Tooltip, { placement: "left", portal: true, content: _jsx(Box, { padding: 3, children: _jsxs(Flex, { align: "flex-start", gap: 3, children: [_jsx(TextWithTone, { tone: "default", size: 3, children: _jsx(HelpCircleIcon, {}, void 0) }, void 0), _jsxs(Stack, { space: 3, children: [_jsx(Text, { as: "h2", size: 1, weight: "semibold", children: "This document is not valid" }, void 0), _jsxs(Text, { size: 1, children: ["ID: ", reference?._ref] }, void 0)] }, void 0)] }, void 0) }, void 0), children: _jsx(TextWithTone, { tone: "default", size: 2, children: _jsx(HelpCircleIcon, {}, void 0) }, void 0) }, void 0)] }, void 0) }, void 0)), props.action] }, void 0));
|
|
45
|
+
};
|
|
46
|
+
export default DocumentInNode;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { TextWithTone } from '@sanity/base/components';
|
|
3
|
+
import { useTimeAgo } from '@sanity/base/hooks';
|
|
4
|
+
import { EditIcon, PublishIcon } from '@sanity/icons';
|
|
5
|
+
import { Box, Inline, Text, Tooltip } from '@sanity/ui';
|
|
6
|
+
export function TimeAgo({ time }) {
|
|
7
|
+
const timeAgo = useTimeAgo(time);
|
|
8
|
+
return _jsxs("span", { title: timeAgo, children: [timeAgo, " ago"] }, void 0);
|
|
9
|
+
}
|
|
10
|
+
const PublishedStatus = ({ document }) => (_jsx(Tooltip, { content: _jsx(Box, { padding: 2, children: _jsx(Text, { size: 1, children: document ? (_jsxs(_Fragment, { children: ["Published ", document._updatedAt && _jsx(TimeAgo, { time: document._updatedAt }, void 0)] }, void 0)) : (_jsx(_Fragment, { children: "Not published" }, void 0)) }, void 0) }, void 0), children: _jsx(TextWithTone, { tone: "positive", dimmed: !document, muted: !document, size: 1, children: _jsx(PublishIcon, {}, void 0) }, void 0) }, void 0));
|
|
11
|
+
const DraftStatus = ({ document }) => (_jsx(Tooltip, { content: _jsx(Box, { padding: 2, children: _jsx(Text, { size: 1, children: document ? (_jsxs(_Fragment, { children: ["Edited ", document?._updatedAt && _jsx(TimeAgo, { time: document?._updatedAt }, void 0)] }, void 0)) : (_jsx(_Fragment, { children: "No unpublished edits" }, void 0)) }, void 0) }, void 0), children: _jsx(TextWithTone, { tone: "caution", dimmed: !document, muted: !document, size: 1, children: _jsx(EditIcon, {}, void 0) }, void 0) }, void 0));
|
|
12
|
+
// Adapted from @sanity\desk-tool\src\components\paneItem\helpers.tsx
|
|
13
|
+
const DocumentPreviewStatus = ({ draft, published }) => {
|
|
14
|
+
return (_jsxs(Inline, { space: 4, children: [_jsx(PublishedStatus, { document: published }, void 0), _jsx(DraftStatus, { document: draft }, void 0)] }, void 0));
|
|
15
|
+
};
|
|
16
|
+
export default DocumentPreviewStatus;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NodeRendererProps } from 'react-sortable-tree';
|
|
3
|
+
/**
|
|
4
|
+
* Applicable only to nodes inside the main tree.
|
|
5
|
+
* Unadded items have their actions defined in TreeEditor.
|
|
6
|
+
*/
|
|
7
|
+
declare const NodeActions: React.FC<{
|
|
8
|
+
nodeProps: NodeRendererProps;
|
|
9
|
+
}>;
|
|
10
|
+
export default NodeActions;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { IntentLink } from '@sanity/base/components';
|
|
3
|
+
import { CopyIcon, EllipsisVerticalIcon, LaunchIcon, RemoveCircleIcon } from '@sanity/icons';
|
|
4
|
+
import { Button, Menu, MenuButton, MenuDivider, MenuItem } from '@sanity/ui';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import useTreeOperations from '../utils/useTreeOperations';
|
|
7
|
+
/**
|
|
8
|
+
* Applicable only to nodes inside the main tree.
|
|
9
|
+
* Unadded items have their actions defined in TreeEditor.
|
|
10
|
+
*/
|
|
11
|
+
const NodeActions = ({ nodeProps }) => {
|
|
12
|
+
const operations = useTreeOperations();
|
|
13
|
+
const { node } = nodeProps;
|
|
14
|
+
// Adapted from @sanity\form-builder\src\inputs\ReferenceInput\ArrayItemReferenceInput.tsx
|
|
15
|
+
const OpenLink = React.useMemo(() =>
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
17
|
+
React.forwardRef(function OpenLinkInner(restProps, _ref) {
|
|
18
|
+
return (_jsx(IntentLink, { ...restProps, intent: "edit", params: { id: node.node?._ref, type: node.nodeDocType }, target: "_blank", rel: "noopener noreferrer", ref: _ref }, void 0));
|
|
19
|
+
}), [node.node?._ref, node?.nodeDocType]);
|
|
20
|
+
const isValid = !!node.publishedId;
|
|
21
|
+
return (_jsx(MenuButton, { button: _jsx(Button, { padding: 2, mode: "bleed", icon: EllipsisVerticalIcon }, void 0), id: `hiearchical-doc-list--${node._key}-menuButton`, menu: _jsxs(Menu, { children: [_jsx(MenuItem, { text: "Remove from list", tone: "critical", icon: RemoveCircleIcon, onClick: () => operations.removeItem(nodeProps) }, void 0), _jsx(MenuItem, { text: "Duplicate", icon: CopyIcon, disabled: !isValid, onClick: () => operations.duplicateItem(nodeProps) }, void 0), _jsx(MenuDivider, {}, void 0), _jsx(MenuItem, { text: "Open in new tab", icon: LaunchIcon, disabled: !isValid, as: OpenLink, "data-as": "a" }, void 0)] }, void 0), placement: "right", popover: { portal: true, tone: 'default' } }, void 0));
|
|
22
|
+
};
|
|
23
|
+
export default NodeActions;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NodeRenderer } from 'react-sortable-tree';
|
|
2
|
+
/**
|
|
3
|
+
* Customization of react-sortable-tree's default node.
|
|
4
|
+
* Created in order to use Sanity UI for styles.
|
|
5
|
+
* Reference: https://github.com/frontend-collective/react-sortable-tree/blob/master/src/node-renderer-default.js
|
|
6
|
+
*/
|
|
7
|
+
declare const NodeContentRenderer: NodeRenderer;
|
|
8
|
+
export default NodeContentRenderer;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cyan, gray, red } from '@sanity/color';
|
|
3
|
+
import { ChevronDownIcon, ChevronRightIcon, DragHandleIcon } from '@sanity/icons';
|
|
4
|
+
import { Box, Button, Flex, Spinner } from '@sanity/ui';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { isDescendant } from 'react-sortable-tree';
|
|
7
|
+
import styled from 'styled-components';
|
|
8
|
+
const Root = styled.div `
|
|
9
|
+
// Adapted from react-sortable-tree/style.css
|
|
10
|
+
&[data-landing='true'] > *,
|
|
11
|
+
&[data-cancel='true'] > * {
|
|
12
|
+
opacity: 0 !important;
|
|
13
|
+
}
|
|
14
|
+
&[data-landing='true']::before,
|
|
15
|
+
&[data-cancel='true']::before {
|
|
16
|
+
background-color: ${cyan[50].hex};
|
|
17
|
+
border: 2px dashed ${gray[400].hex};
|
|
18
|
+
border-radius: 3px;
|
|
19
|
+
content: '';
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 0;
|
|
22
|
+
right: 0;
|
|
23
|
+
bottom: 0;
|
|
24
|
+
left: 0;
|
|
25
|
+
z-index: -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
&[data-cancel='true']::before {
|
|
29
|
+
background-color: ${red[50].hex};
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
/**
|
|
33
|
+
* Customization of react-sortable-tree's default node.
|
|
34
|
+
* Created in order to use Sanity UI for styles.
|
|
35
|
+
* Reference: https://github.com/frontend-collective/react-sortable-tree/blob/master/src/node-renderer-default.js
|
|
36
|
+
*/
|
|
37
|
+
const NodeContentRenderer = (props) => {
|
|
38
|
+
const { node, path, treeIndex, canDrag = false } = props;
|
|
39
|
+
const nodeTitle = node.title;
|
|
40
|
+
const Handle = React.useMemo(() => {
|
|
41
|
+
if (!canDrag) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof node.children === 'function' && node.expanded) {
|
|
45
|
+
// Show a loading symbol on the handle when the children are expanded
|
|
46
|
+
// and yet still defined by a function (a callback to fetch the children)
|
|
47
|
+
return _jsx(Spinner, {}, void 0);
|
|
48
|
+
}
|
|
49
|
+
const BtnElement = (_jsx("div", { children: _jsx(Button, { mode: "bleed", paddingX: 0, paddingY: 1, style: {
|
|
50
|
+
cursor: node.publishedId ? 'grab' : 'default',
|
|
51
|
+
fontSize: '1.5625rem'
|
|
52
|
+
}, "data-ui": "DragHandleButton", "data-drag-handle": canDrag, disabled: !node.publishedId, children: _jsx(DragHandleIcon, { style: { marginBottom: '-0.1em' } }, void 0) }, void 0) }, void 0));
|
|
53
|
+
// Don't allow editors to drag invalid documents
|
|
54
|
+
if (!node.publishedId) {
|
|
55
|
+
return BtnElement;
|
|
56
|
+
}
|
|
57
|
+
// Show the handle used to initiate a drag-and-drop
|
|
58
|
+
return props.connectDragSource(BtnElement, {
|
|
59
|
+
dropEffect: 'copy'
|
|
60
|
+
});
|
|
61
|
+
}, [canDrag, node, typeof node.children === 'function']);
|
|
62
|
+
const isDraggedDescendant = props.draggedNode && isDescendant(props.draggedNode, node);
|
|
63
|
+
const isLandingPadActive = !props.didDrop && props.isDragging;
|
|
64
|
+
return (_jsxs(Box, { style: { position: 'relative' }, children: [props.toggleChildrenVisibility &&
|
|
65
|
+
node.children &&
|
|
66
|
+
(node.children.length > 0 || typeof node.children === 'function') && (_jsx("div", { style: {
|
|
67
|
+
position: 'absolute',
|
|
68
|
+
left: '-2px',
|
|
69
|
+
top: '40%',
|
|
70
|
+
transform: 'translate(-100%, -50%)'
|
|
71
|
+
}, children: _jsx(Button, { "aria-label": node.expanded ? 'Collapse' : 'Expand', icon: node.expanded ? (_jsx(ChevronDownIcon, { color: gray[200].hex }, void 0)) : (_jsx(ChevronRightIcon, { color: gray[200].hex }, void 0)), mode: "bleed", fontSize: 2, padding: 1, type: "button", onClick: () => props.toggleChildrenVisibility?.({
|
|
72
|
+
node,
|
|
73
|
+
path,
|
|
74
|
+
treeIndex
|
|
75
|
+
}) }, void 0) }, void 0)), props.connectDragPreview(_jsx("div", { children: _jsx(Root, { "data-landing": isLandingPadActive, "data-cancel": isLandingPadActive && !props.canDrop, style: {
|
|
76
|
+
opacity: isDraggedDescendant ? 0.5 : 1
|
|
77
|
+
}, children: _jsxs(Flex, { align: "center", children: [Handle, typeof nodeTitle === 'function' ? nodeTitle(props) : nodeTitle] }, void 0) }, void 0) }, void 0))] }, void 0));
|
|
78
|
+
};
|
|
79
|
+
export default NodeContentRenderer;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CardTone } from '@sanity/ui';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { PlaceholderRendererProps } from 'react-sortable-tree';
|
|
4
|
+
declare const PlaceholderDropzone: React.FC<{
|
|
5
|
+
tone?: CardTone;
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
} & PlaceholderRendererProps>;
|
|
9
|
+
export default PlaceholderDropzone;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Card, Stack, Text } from '@sanity/ui';
|
|
3
|
+
const PlaceholderDropzone = (props) => {
|
|
4
|
+
const isValid = props.isOver && props.canDrop;
|
|
5
|
+
const isInvalid = props.isOver && !props.canDrop;
|
|
6
|
+
let tone = 'transparent';
|
|
7
|
+
if (isValid) {
|
|
8
|
+
tone = 'positive';
|
|
9
|
+
}
|
|
10
|
+
if (isInvalid) {
|
|
11
|
+
tone = 'caution';
|
|
12
|
+
}
|
|
13
|
+
return (_jsx(Card, { padding: 5, radius: 2, border: true, tone: tone, style: {
|
|
14
|
+
borderStyle: props.isOver ? undefined : 'dashed'
|
|
15
|
+
}, children: _jsxs(Stack, { space: 2, style: { textAlign: 'center' }, children: [_jsxs(Text, { size: 2, as: "h2", muted: true, children: [!props.isOver && props.title, isValid && 'Drop here', isInvalid && 'Invalid location or element'] }, void 0), props.subtitle && _jsx(Text, { size: 1, children: props.subtitle }, void 0), props.children] }, void 0) }, void 0));
|
|
16
|
+
};
|
|
17
|
+
export default PlaceholderDropzone;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SanityTreeItem, TreeInputOptions } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* The loaded tree users interact with
|
|
5
|
+
*/
|
|
6
|
+
declare const TreeEditor: React.FC<{
|
|
7
|
+
tree: SanityTreeItem[];
|
|
8
|
+
onChange: (patch: unknown) => void;
|
|
9
|
+
options: TreeInputOptions;
|
|
10
|
+
patchPrefix?: string;
|
|
11
|
+
}>;
|
|
12
|
+
export default TreeEditor;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AddCircleIcon } from '@sanity/icons';
|
|
3
|
+
import { Box, Button, Card, Flex, Spinner, Stack, Text, Tooltip } from '@sanity/ui';
|
|
4
|
+
import SortableTree from 'react-sortable-tree';
|
|
5
|
+
import getCommonTreeProps from '../utils/getCommonTreeProps';
|
|
6
|
+
import getTreeHeight from '../utils/getTreeHeight';
|
|
7
|
+
import { getUnaddedItems } from '../utils/treeData';
|
|
8
|
+
import useAllItems from '../utils/useAllItems';
|
|
9
|
+
import useLocalTree from '../utils/useLocalTree';
|
|
10
|
+
import { TreeOperationsContext } from '../utils/useTreeOperations';
|
|
11
|
+
import useTreeOperationsProvider from '../utils/useTreeOperationsProvider';
|
|
12
|
+
import DocumentInNode from './DocumentInNode';
|
|
13
|
+
import TreeEditorErrorBoundary from './TreeEditorErrorBoundary';
|
|
14
|
+
/**
|
|
15
|
+
* The loaded tree users interact with
|
|
16
|
+
*/
|
|
17
|
+
const TreeEditor = (props) => {
|
|
18
|
+
const { status: allItemsStatus, allItems } = useAllItems(props.options);
|
|
19
|
+
const unaddedItems = getUnaddedItems({ tree: props.tree, allItems });
|
|
20
|
+
const { localTree, handleVisibilityToggle } = useLocalTree({
|
|
21
|
+
tree: props.tree,
|
|
22
|
+
allItems
|
|
23
|
+
});
|
|
24
|
+
const operations = useTreeOperationsProvider({
|
|
25
|
+
patchPrefix: props.patchPrefix,
|
|
26
|
+
onChange: props.onChange,
|
|
27
|
+
localTree
|
|
28
|
+
});
|
|
29
|
+
return (_jsx(TreeEditorErrorBoundary, { children: _jsx(TreeOperationsContext.Provider, { value: { ...operations, allItemsStatus }, children: _jsxs(Stack, { space: 4, paddingTop: 4, children: [_jsx(Card, { style: { minHeight: getTreeHeight(localTree) },
|
|
30
|
+
// Only include borderBottom if there's something to show in unadded items
|
|
31
|
+
borderBottom: allItemsStatus !== 'success' || unaddedItems?.length > 0, children: _jsx(SortableTree, { maxDepth: props.options.maxDepth, onChange: () => {
|
|
32
|
+
// Do nothing. onMoveNode will do all the work
|
|
33
|
+
}, onVisibilityToggle: handleVisibilityToggle, onMoveNode: operations.handleMovedNode, treeData: localTree, ...getCommonTreeProps({
|
|
34
|
+
placeholder: {
|
|
35
|
+
title: 'Add items by dragging them here'
|
|
36
|
+
}
|
|
37
|
+
}) }, void 0) }, void 0), allItemsStatus === 'success' && unaddedItems?.length > 0 && (_jsxs(Stack, { space: 1, paddingX: 2, paddingTop: 3, children: [_jsxs(Stack, { space: 2, paddingX: 2, paddingBottom: 3, children: [_jsx(Text, { size: 2, as: "h2", weight: "semibold", children: "Add more items" }, void 0), _jsx(Text, { size: 1, muted: true, children: "Only published documents are shown." }, void 0)] }, void 0), unaddedItems.map((item) => (_jsx(DocumentInNode, { item: item, action: _jsx(Tooltip, { placement: "left", content: _jsx(Box, { padding: 2, children: _jsx(Text, { size: 1, children: "Add to list" }, void 0) }, void 0), children: _jsx(Button, { onClick: () => {
|
|
38
|
+
operations.addItem(item);
|
|
39
|
+
}, mode: "bleed", icon: AddCircleIcon, style: { cursor: 'pointer' } }, void 0) }, void 0) }, item.publishedId || item.draftId)))] }, void 0)), allItemsStatus === 'loading' && (_jsx(Flex, { padding: 4, align: 'center', justify: 'center', children: _jsx(Spinner, { size: 3, muted: true }, void 0) }, void 0)), allItemsStatus === 'error' && (_jsx(Flex, { padding: 4, align: 'center', justify: 'center', children: _jsx(Text, { size: 2, weight: "semibold", children: "Something went wrong when loading documents" }, void 0) }, void 0))] }, void 0) }, void 0) }, void 0));
|
|
40
|
+
};
|
|
41
|
+
export default TreeEditor;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface ErrorInfo {
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
declare class TreeEditorErrorBoundary extends React.Component<any, {
|
|
7
|
+
error?: ErrorInfo;
|
|
8
|
+
}> {
|
|
9
|
+
constructor(props: any);
|
|
10
|
+
static getDerivedStateFromError(error: unknown): {
|
|
11
|
+
error: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
error: ErrorInfo;
|
|
14
|
+
};
|
|
15
|
+
render(): JSX.Element;
|
|
16
|
+
}
|
|
17
|
+
export default TreeEditorErrorBoundary;
|