sanity-plugin-workflow 1.0.0-beta.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 +62 -0
- package/lib/index.esm.js +1 -0
- package/lib/index.esm.js.map +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -0
- package/lib/src/index.d.ts +19 -0
- package/package.json +95 -0
- package/sanity.json +8 -0
- package/src/actions/DemoteAction.tsx +62 -0
- package/src/actions/PromoteAction.tsx +62 -0
- package/src/actions/RequestReviewAction.js +61 -0
- package/src/actions/index.js +21 -0
- package/src/badges/index.tsx +31 -0
- package/src/components/DocumentCard/AvatarGroup.tsx +39 -0
- package/src/components/DocumentCard/EditButton.tsx +27 -0
- package/src/components/DocumentCard/index.tsx +92 -0
- package/src/components/Mutate.tsx +54 -0
- package/src/components/StateTimeline.tsx +98 -0
- package/src/components/UserAssignment.tsx +139 -0
- package/src/components/UserSelectInput.tsx +43 -0
- package/src/components/WorkflowTool.tsx +228 -0
- package/src/hooks/useWorkflowDocuments.tsx +166 -0
- package/src/hooks/useWorkflowMetadata.tsx +44 -0
- package/src/index.ts +94 -0
- package/src/schema/workflow/metadata.ts +38 -0
- package/src/types/index.ts +50 -0
- package/v2-incompatible.js +11 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {useListeningQuery} from 'sanity-plugin-utils'
|
|
3
|
+
import {useToast} from '@sanity/ui'
|
|
4
|
+
import {SanityDocumentLike, useClient} from 'sanity'
|
|
5
|
+
import {DraggableLocation} from 'react-beautiful-dnd'
|
|
6
|
+
import {SanityDocumentWithMetadata, Metadata, State} from '../types'
|
|
7
|
+
|
|
8
|
+
type DocumentsAndMetadata = {
|
|
9
|
+
documents: SanityDocumentLike[]
|
|
10
|
+
metadata: Metadata[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DOCUMENT_LIST_QUERY = `*[_type in $schemaTypes]{ _id, _type, _rev }`
|
|
14
|
+
const METADATA_LIST_QUERY = `*[_type == "workflow.metadata"]{
|
|
15
|
+
_rev,
|
|
16
|
+
assignees,
|
|
17
|
+
documentId,
|
|
18
|
+
state
|
|
19
|
+
}`
|
|
20
|
+
|
|
21
|
+
const COMBINED_QUERY = `{
|
|
22
|
+
"documents": ${DOCUMENT_LIST_QUERY},
|
|
23
|
+
"metadata": ${METADATA_LIST_QUERY}
|
|
24
|
+
}`
|
|
25
|
+
|
|
26
|
+
const INITIAL_DATA: DocumentsAndMetadata = {
|
|
27
|
+
documents: [],
|
|
28
|
+
metadata: [],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useWorkflowDocuments(schemaTypes: string[]) {
|
|
32
|
+
const toast = useToast()
|
|
33
|
+
const client = useClient()
|
|
34
|
+
const [localDocuments, setLocalDocuments] = React.useState<
|
|
35
|
+
SanityDocumentWithMetadata[]
|
|
36
|
+
>([])
|
|
37
|
+
|
|
38
|
+
// Get and listen to changes on documents + workflow metadata documents
|
|
39
|
+
const {data, loading, error} = useListeningQuery<DocumentsAndMetadata>(
|
|
40
|
+
COMBINED_QUERY,
|
|
41
|
+
{
|
|
42
|
+
params: {schemaTypes},
|
|
43
|
+
initialValue: INITIAL_DATA,
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Store local state for optimistic updates
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (data) {
|
|
50
|
+
// Combine metadata data into document
|
|
51
|
+
const documentsWithMetadata = data.documents.reduce(
|
|
52
|
+
(acc: SanityDocumentWithMetadata[], cur) => {
|
|
53
|
+
// Filter out documents without metadata
|
|
54
|
+
const curMeta = data.metadata.find(
|
|
55
|
+
(d) => d.documentId === cur._id.replace(`drafts.`, ``)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Add _metadata as null so it can be shown as a document that needs to be imported into workflow
|
|
59
|
+
if (!curMeta) {
|
|
60
|
+
return [...acc, {_metadata: null, ...cur}]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const curWithMetadata = {_metadata: curMeta, ...cur}
|
|
64
|
+
|
|
65
|
+
// Remove `published` from array if `draft` exists
|
|
66
|
+
if (!cur._id.startsWith(`drafts.`)) {
|
|
67
|
+
// eslint-disable-next-line max-nested-callbacks
|
|
68
|
+
const alsoHasDraft: boolean = Boolean(
|
|
69
|
+
data.documents.find((doc) => doc._id === `drafts.${cur._id}`)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return alsoHasDraft ? acc : [...acc, curWithMetadata]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [...acc, curWithMetadata]
|
|
76
|
+
},
|
|
77
|
+
[]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
setLocalDocuments(documentsWithMetadata)
|
|
81
|
+
}
|
|
82
|
+
}, [data])
|
|
83
|
+
|
|
84
|
+
const move = React.useCallback(
|
|
85
|
+
(draggedId: string, destination: DraggableLocation, states: State[]) => {
|
|
86
|
+
// Optimistic update
|
|
87
|
+
const currentLocalData = localDocuments
|
|
88
|
+
const newLocalDocuments = localDocuments.map((item) => {
|
|
89
|
+
if (item?._metadata?.documentId === draggedId) {
|
|
90
|
+
return {
|
|
91
|
+
...item,
|
|
92
|
+
_metadata: {
|
|
93
|
+
...item._metadata,
|
|
94
|
+
state: destination.droppableId,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return item
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
setLocalDocuments(newLocalDocuments)
|
|
103
|
+
|
|
104
|
+
// Now client-side update
|
|
105
|
+
const newStateId = destination.droppableId
|
|
106
|
+
const newState = states.find((s) => s.id === newStateId)
|
|
107
|
+
const document = localDocuments.find(
|
|
108
|
+
(d) => d?._metadata?.documentId === draggedId
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (!newState?.id) {
|
|
112
|
+
toast.push({
|
|
113
|
+
title: `Could not find target state ${newStateId}`,
|
|
114
|
+
status: 'error',
|
|
115
|
+
})
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!document) {
|
|
120
|
+
toast.push({
|
|
121
|
+
title: `Could not find dragged document in data`,
|
|
122
|
+
status: 'error',
|
|
123
|
+
})
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// We need to know if it's a draft or not
|
|
128
|
+
const {_id, _type} = document
|
|
129
|
+
|
|
130
|
+
// Metadata + useDocumentOperation always uses Published id
|
|
131
|
+
const {_rev, documentId} = document._metadata || {}
|
|
132
|
+
|
|
133
|
+
client
|
|
134
|
+
.patch(`workflow-metadata.${documentId}`)
|
|
135
|
+
.ifRevisionId(_rev as string)
|
|
136
|
+
.set({state: newStateId})
|
|
137
|
+
.commit()
|
|
138
|
+
.then(() => {
|
|
139
|
+
return toast.push({
|
|
140
|
+
title: `Moved to "${newState?.title ?? newStateId}"`,
|
|
141
|
+
description: documentId,
|
|
142
|
+
status: 'success',
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
.catch(() => {
|
|
146
|
+
// Revert optimistic update
|
|
147
|
+
setLocalDocuments(currentLocalData)
|
|
148
|
+
|
|
149
|
+
return toast.push({
|
|
150
|
+
title: `Failed to move to "${newState?.title ?? newStateId}"`,
|
|
151
|
+
description: documentId,
|
|
152
|
+
status: 'error',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Send back to the workflow board so a document update can happen
|
|
157
|
+
return {_id, _type, documentId, state: newState as State}
|
|
158
|
+
},
|
|
159
|
+
[client, toast, localDocuments]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
workflowData: {data: localDocuments, loading, error},
|
|
164
|
+
operations: {move},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {useListeningQuery} from 'sanity-plugin-utils'
|
|
3
|
+
|
|
4
|
+
import {Metadata, State} from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Takes the published ID of a document and return the metadata and current state object
|
|
8
|
+
*
|
|
9
|
+
* @param id Source document published ID
|
|
10
|
+
* @param states Array of States defined in plugin config
|
|
11
|
+
* @returns State
|
|
12
|
+
*/
|
|
13
|
+
export function useWorkflowMetadata(
|
|
14
|
+
id: string,
|
|
15
|
+
states: State[]
|
|
16
|
+
): {
|
|
17
|
+
data: {metadata?: Metadata; state?: State}
|
|
18
|
+
loading: boolean
|
|
19
|
+
error: boolean
|
|
20
|
+
} {
|
|
21
|
+
const {
|
|
22
|
+
data: metadata,
|
|
23
|
+
loading,
|
|
24
|
+
error,
|
|
25
|
+
} = useListeningQuery<Metadata>(
|
|
26
|
+
`*[_type == "workflow.metadata" && documentId == $id][0]`,
|
|
27
|
+
{
|
|
28
|
+
params: {id},
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (metadata?.state) {
|
|
33
|
+
return {
|
|
34
|
+
data: {
|
|
35
|
+
metadata,
|
|
36
|
+
state: states.find((s) => s.id === metadata.state),
|
|
37
|
+
},
|
|
38
|
+
loading,
|
|
39
|
+
error,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {data: {}, loading, error}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {definePlugin} from 'sanity'
|
|
2
|
+
import {CheckmarkIcon, SplitVerticalIcon} from '@sanity/icons'
|
|
3
|
+
|
|
4
|
+
import WorkflowTool from './components/WorkflowTool'
|
|
5
|
+
import {WorkflowConfig} from './types'
|
|
6
|
+
import metadata from './schema/workflow/metadata'
|
|
7
|
+
import {StateBadge} from './badges'
|
|
8
|
+
import {PromoteAction} from './actions/PromoteAction'
|
|
9
|
+
import {DemoteAction} from './actions/DemoteAction'
|
|
10
|
+
//import StateTimeline from './components/StateTimeline'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG: WorkflowConfig = {
|
|
13
|
+
schemaTypes: [],
|
|
14
|
+
states: [
|
|
15
|
+
{id: 'draft', title: 'Draft', operation: 'unpublish'},
|
|
16
|
+
{id: 'inReview', title: 'In review', operation: null, color: 'primary'},
|
|
17
|
+
{
|
|
18
|
+
id: 'approved',
|
|
19
|
+
title: 'Approved',
|
|
20
|
+
operation: null,
|
|
21
|
+
color: 'success',
|
|
22
|
+
icon: CheckmarkIcon,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'changesRequested',
|
|
26
|
+
title: 'Changes requested',
|
|
27
|
+
operation: null,
|
|
28
|
+
color: 'warning',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'published',
|
|
32
|
+
title: 'Published',
|
|
33
|
+
operation: 'publish',
|
|
34
|
+
color: 'success',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const workflow = definePlugin<WorkflowConfig>(
|
|
40
|
+
(config = DEFAULT_CONFIG) => {
|
|
41
|
+
const {schemaTypes, states} = {...DEFAULT_CONFIG, ...config}
|
|
42
|
+
|
|
43
|
+
if (!states?.length) {
|
|
44
|
+
throw new Error(`Workflow: Missing states in config`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: 'sanity-plugin-workflow',
|
|
49
|
+
schema: {
|
|
50
|
+
types: [metadata(states)],
|
|
51
|
+
},
|
|
52
|
+
// form: {
|
|
53
|
+
// components: {
|
|
54
|
+
// item: (props) => {
|
|
55
|
+
// console.log(props)
|
|
56
|
+
// // if (props.id === `root` && schemaTypes.includes(props.schemaType.name)) {
|
|
57
|
+
// // return StateTimeline(props)
|
|
58
|
+
// // }
|
|
59
|
+
// return props.renderDefault(props)
|
|
60
|
+
// },
|
|
61
|
+
// },
|
|
62
|
+
// },
|
|
63
|
+
document: {
|
|
64
|
+
actions: (prev, context) => {
|
|
65
|
+
if (!schemaTypes.includes(context.schemaType)) {
|
|
66
|
+
return prev
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
(props) => PromoteAction(props, states),
|
|
71
|
+
(props) => DemoteAction(props, states),
|
|
72
|
+
...prev,
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
badges: (prev, context) => {
|
|
76
|
+
if (!schemaTypes.includes(context.schemaType)) {
|
|
77
|
+
return prev
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [(props) => StateBadge(props, states), ...prev]
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
tools: [
|
|
84
|
+
{
|
|
85
|
+
name: 'workflow',
|
|
86
|
+
title: 'Workflow',
|
|
87
|
+
component: WorkflowTool,
|
|
88
|
+
icon: SplitVerticalIcon,
|
|
89
|
+
options: {schemaTypes, states},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {defineType, defineField, defineArrayMember} from 'sanity'
|
|
2
|
+
// import UserSelectInput from '../../components/UserSelectInput'
|
|
3
|
+
import {State} from '../../types'
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
6
|
+
export default (states: State[]) =>
|
|
7
|
+
defineType({
|
|
8
|
+
type: 'document',
|
|
9
|
+
name: 'workflow.metadata',
|
|
10
|
+
title: 'Workflow metadata',
|
|
11
|
+
liveEdit: true,
|
|
12
|
+
fields: [
|
|
13
|
+
defineField({
|
|
14
|
+
name: 'state',
|
|
15
|
+
type: 'string',
|
|
16
|
+
options: {
|
|
17
|
+
list: states.map((state) => ({
|
|
18
|
+
value: state.id,
|
|
19
|
+
title: state.title,
|
|
20
|
+
})),
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
defineField({
|
|
24
|
+
name: 'documentId',
|
|
25
|
+
title: 'Document ID',
|
|
26
|
+
type: 'string',
|
|
27
|
+
readOnly: true,
|
|
28
|
+
}),
|
|
29
|
+
defineField({
|
|
30
|
+
type: 'array',
|
|
31
|
+
name: 'assignees',
|
|
32
|
+
description:
|
|
33
|
+
'The people who are assigned to move this further in the workflow.',
|
|
34
|
+
of: [defineArrayMember({type: 'string'})],
|
|
35
|
+
// components: {input: UserSelectInput},
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {SanityDocumentLike} from 'sanity'
|
|
3
|
+
|
|
4
|
+
export type State = {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
operation?: 'publish' | 'unpublish' | null
|
|
8
|
+
// From badge props
|
|
9
|
+
color?: 'primary' | 'success' | 'warning' | 'danger'
|
|
10
|
+
icon?: React.ReactNode | React.ComponentType
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type WorkflowConfig = {
|
|
14
|
+
schemaTypes: string[]
|
|
15
|
+
states?: State[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type User = {
|
|
19
|
+
createdAt: string
|
|
20
|
+
displayName: string
|
|
21
|
+
email: string
|
|
22
|
+
familyName: string
|
|
23
|
+
givenName: string
|
|
24
|
+
id: string
|
|
25
|
+
imageUrl: string
|
|
26
|
+
isCurrentUser: boolean
|
|
27
|
+
middleName: string
|
|
28
|
+
projectId: string
|
|
29
|
+
provider: string
|
|
30
|
+
sanityUserId: string
|
|
31
|
+
updatedAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DragData = {
|
|
35
|
+
documentId?: string
|
|
36
|
+
x?: number
|
|
37
|
+
y?: number
|
|
38
|
+
state?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Metadata = SanityDocumentLike & {
|
|
42
|
+
_rev: string
|
|
43
|
+
assignees: string[]
|
|
44
|
+
documentId: string
|
|
45
|
+
state: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SanityDocumentWithMetadata = SanityDocumentLike & {
|
|
49
|
+
_metadata: Metadata | null
|
|
50
|
+
}
|
|
@@ -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
|
+
})
|