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