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,92 @@
1
+ /* eslint-disable react/prop-types */
2
+ import {
3
+ Box,
4
+ Button,
5
+ Card,
6
+ Flex,
7
+ Popover,
8
+ Stack,
9
+ useClickOutside,
10
+ useTheme,
11
+ } from '@sanity/ui'
12
+ import {AddIcon, DragHandleIcon} from '@sanity/icons'
13
+ import React, {useState} from 'react'
14
+ import {useSchema, SchemaType} from 'sanity'
15
+ import {UserSelectMenu} from 'sanity-plugin-utils'
16
+ import {Preview} from 'sanity'
17
+
18
+ import EditButton from './EditButton'
19
+ import {SanityDocumentWithMetadata, User} from '../../types'
20
+ import AvatarGroup from './AvatarGroup'
21
+ import UserAssignment from '../UserAssignment'
22
+
23
+ type DocumentCardProps = {
24
+ userList: User[]
25
+ isDragging: boolean
26
+ item: SanityDocumentWithMetadata
27
+ }
28
+
29
+ export function DocumentCard(props: DocumentCardProps) {
30
+ const {userList, isDragging, item} = props
31
+ const {assignees = [], documentId} = item._metadata ?? {}
32
+ const schema = useSchema()
33
+
34
+ const isDarkMode = useTheme().sanity.color.dark
35
+ const defaultCardTone = isDarkMode ? 'transparent' : 'default'
36
+
37
+ // Open/close handler
38
+ // const [popoverRef, setPopoverRef] = useState(null)
39
+ // const [openId, setOpenId] = useState<string | undefined>(``)
40
+
41
+ // useClickOutside(() => setOpenId(``), [popoverRef])
42
+
43
+ // const handleKeyDown = React.useCallback((e) => {
44
+ // if (e.key === 'Escape') {
45
+ // setOpenId(``)
46
+ // }
47
+ // }, [])
48
+
49
+ return (
50
+ <Box paddingY={2} paddingX={3}>
51
+ <Card
52
+ radius={2}
53
+ shadow={isDragging ? 3 : 1}
54
+ tone={isDragging ? 'positive' : defaultCardTone}
55
+ >
56
+ <Stack>
57
+ <Card
58
+ borderBottom
59
+ radius={2}
60
+ padding={3}
61
+ paddingLeft={2}
62
+ tone="inherit"
63
+ style={{pointerEvents: 'none'}}
64
+ >
65
+ <Flex align="center" justify="space-between" gap={1}>
66
+ <Preview
67
+ layout="default"
68
+ value={item}
69
+ schemaType={schema.get(item._type) as SchemaType}
70
+ />
71
+ <DragHandleIcon style={{flexShrink: 0}} />
72
+ </Flex>
73
+ </Card>
74
+
75
+ <Card padding={2} radius={2} tone="inherit">
76
+ <Flex align="center" justify="space-between" gap={1}>
77
+ {documentId && (
78
+ <UserAssignment
79
+ userList={userList}
80
+ assignees={assignees}
81
+ documentId={documentId}
82
+ />
83
+ )}
84
+
85
+ <EditButton id={item._id} type={item._type} />
86
+ </Flex>
87
+ </Card>
88
+ </Stack>
89
+ </Card>
90
+ </Box>
91
+ )
92
+ }
@@ -0,0 +1,54 @@
1
+ import React from 'react'
2
+ import {Card, useToast} from '@sanity/ui'
3
+ import {useDocumentOperation} from 'sanity'
4
+
5
+ import {State} from '../types'
6
+
7
+ type MutateProps = {
8
+ _id: string
9
+ _type: string
10
+ state: State
11
+ documentId: string
12
+ onComplete: (id: string) => void
13
+ }
14
+
15
+ export default function Mutate(props: MutateProps) {
16
+ const {_id, _type, documentId, state, onComplete} = props
17
+ const ops = useDocumentOperation(documentId, _type)
18
+ const isDraft = _id.startsWith('drafts.')
19
+
20
+ const toast = useToast()
21
+
22
+ if (isDraft && state.operation === 'publish') {
23
+ if (!ops.publish.disabled) {
24
+ ops.publish.execute()
25
+ onComplete(_id)
26
+ toast.push({
27
+ title: 'Published Document',
28
+ description: documentId,
29
+ status: 'success',
30
+ })
31
+ }
32
+ } else if (!isDraft && state.operation === 'unpublish') {
33
+ if (!ops.unpublish.disabled) {
34
+ ops.unpublish.execute()
35
+ onComplete(_id)
36
+ toast.push({
37
+ title: 'Unpublished Document',
38
+ description: documentId,
39
+ status: 'success',
40
+ })
41
+ }
42
+ } else {
43
+ // Clean up if it's not going to un/publish
44
+ onComplete(_id)
45
+ }
46
+
47
+ // return null
48
+
49
+ return (
50
+ <Card padding={3} shadow={2} tone="primary">
51
+ Mutating: {_id} to {state.title}
52
+ </Card>
53
+ )
54
+ }
@@ -0,0 +1,98 @@
1
+ import {Button, Card, Text, Inline, Stack, useToast} from '@sanity/ui'
2
+ import React, {useEffect} from 'react'
3
+ import {ObjectInputProps, useClient} from 'sanity'
4
+
5
+ import {useWorkflowMetadata} from '../hooks/useWorkflowMetadata'
6
+ import {State} from '../types'
7
+
8
+ type StateTimelineProps = ObjectInputProps & {
9
+ states: State[]
10
+ children: React.ReactNode
11
+ }
12
+
13
+ export default function StateTimeline(props: StateTimelineProps) {
14
+ // return (
15
+ // <Stack space={3}>
16
+ // <StateTimeline {...props} states={states}>
17
+ // {props.renderDefault(props)}
18
+ // </StateTimeline>
19
+ // </Stack>
20
+ // )
21
+ console.log(props)
22
+ const {value, states, children} = props
23
+
24
+ const documentId = String(value?._id)
25
+
26
+ const {data, loading, error} = useWorkflowMetadata(documentId, states)
27
+ const {state} = data
28
+ const [mutatingToState, setMutatingToState] = React.useState<string | null>(
29
+ null
30
+ )
31
+
32
+ const client = useClient()
33
+ const toast = useToast()
34
+
35
+ // Just because the document is patched ...
36
+ // doesn't mean the latest data has been returned from the listener
37
+ useEffect(() => {
38
+ if (data) {
39
+ setMutatingToState(null)
40
+ }
41
+ }, [data])
42
+
43
+ const changeState = React.useCallback(
44
+ (publishedId: string, newState: State) => {
45
+ setMutatingToState(newState.id)
46
+
47
+ client
48
+ .patch(`workflow-metadata.${publishedId}`)
49
+ .set({state: newState.id})
50
+ .commit()
51
+ .then(() => {
52
+ toast.push({
53
+ status: 'success',
54
+ title: `Document moved to ${newState.title}`,
55
+ })
56
+ })
57
+ .catch((err) => {
58
+ console.error(err)
59
+ toast.push({
60
+ status: 'error',
61
+ title: `Document moved failed`,
62
+ })
63
+ })
64
+ },
65
+ [client, toast]
66
+ )
67
+
68
+ return (
69
+ <Stack space={3}>
70
+ <Text weight="medium" size={1}>
71
+ Workflow State
72
+ </Text>
73
+ <Card padding={1} radius={3} border tone="primary">
74
+ <Inline space={1}>
75
+ {states.map((s) => (
76
+ <Button
77
+ disabled={loading || error || Boolean(mutatingToState)}
78
+ fontSize={1}
79
+ tone="primary"
80
+ mode={
81
+ (!mutatingToState && s.id === state?.id) ||
82
+ s.id === mutatingToState
83
+ ? `default`
84
+ : `ghost`
85
+ }
86
+ key={s.id}
87
+ text={s.title}
88
+ radius={2}
89
+ onClick={() => changeState(documentId, s)}
90
+ />
91
+ ))}
92
+ </Inline>
93
+ </Card>
94
+
95
+ {children}
96
+ </Stack>
97
+ )
98
+ }
@@ -0,0 +1,139 @@
1
+ import React from 'react'
2
+ import {Button, Popover, useToast} from '@sanity/ui'
3
+ import {AddIcon} from '@sanity/icons'
4
+ import {UserSelectMenu} from 'sanity-plugin-utils'
5
+ import {useClient} from 'sanity'
6
+
7
+ import AvatarGroup from './DocumentCard/AvatarGroup'
8
+ import {User} from '../types'
9
+
10
+ type UserAssignmentProps = {
11
+ userList: User[]
12
+ assignees: string[]
13
+ documentId: string
14
+ }
15
+
16
+ export default function UserAssignment(props: UserAssignmentProps) {
17
+ const {assignees, userList, documentId} = props
18
+ const client = useClient()
19
+ const toast = useToast()
20
+ const [openId, setOpenId] = React.useState<string>(``)
21
+
22
+ const addAssignee = React.useCallback(
23
+ (userId: string) => {
24
+ if (!userId) {
25
+ return toast.push({
26
+ status: 'error',
27
+ title: 'No user selected',
28
+ })
29
+ }
30
+
31
+ client
32
+ .patch(`workflow-metadata.${documentId}`)
33
+ .setIfMissing({assignees: []})
34
+ .insert(`after`, `assignees[-1]`, [userId])
35
+ .commit()
36
+ .then(() => {
37
+ return toast.push({
38
+ title: `Assigned user to document`,
39
+ description: userId,
40
+ status: 'success',
41
+ })
42
+ })
43
+ .catch((err) => {
44
+ console.error(err)
45
+
46
+ return toast.push({
47
+ title: `Failed to add assignee`,
48
+ description: userId,
49
+ status: 'error',
50
+ })
51
+ })
52
+ },
53
+ [documentId, client, toast]
54
+ )
55
+
56
+ const removeAssignee = React.useCallback(
57
+ (id: string, userId: string) => {
58
+ client
59
+ .patch(`workflow-metadata.${id}`)
60
+ .unset([`assignees[@ == "${userId}"]`])
61
+ .commit()
62
+ .then((res) => res)
63
+ .catch((err) => {
64
+ console.error(err)
65
+
66
+ return toast.push({
67
+ title: `Failed to remove assignee`,
68
+ description: id,
69
+ status: 'error',
70
+ })
71
+ })
72
+ },
73
+ [client, toast]
74
+ )
75
+
76
+ const clearAssignees = React.useCallback(
77
+ (id: string) => {
78
+ client
79
+ .patch(`workflow-metadata.${id}`)
80
+ .unset([`assignees`])
81
+ .commit()
82
+ .then((res) => res)
83
+ .catch((err) => {
84
+ console.error(err)
85
+
86
+ return toast.push({
87
+ title: `Failed to clear assignees`,
88
+ description: id,
89
+ status: 'error',
90
+ })
91
+ })
92
+ },
93
+ [client, toast]
94
+ )
95
+
96
+ return (
97
+ <Popover
98
+ // @ts-ignore
99
+ // ref={setPopoverRef}
100
+ // onKeyDown={handleKeyDown}
101
+ content={
102
+ <UserSelectMenu
103
+ style={{maxHeight: 300}}
104
+ value={assignees || []}
105
+ userList={userList}
106
+ onAdd={addAssignee}
107
+ onClear={clearAssignees}
108
+ onRemove={removeAssignee}
109
+ open={openId === documentId}
110
+ />
111
+ }
112
+ portal
113
+ open={openId === documentId}
114
+ >
115
+ {!assignees || assignees.length === 0 ? (
116
+ <Button
117
+ onClick={() => setOpenId(documentId)}
118
+ fontSize={1}
119
+ padding={2}
120
+ tabIndex={-1}
121
+ icon={AddIcon}
122
+ text="Assign"
123
+ tone="positive"
124
+ />
125
+ ) : (
126
+ <Button
127
+ onClick={() => setOpenId(documentId)}
128
+ padding={0}
129
+ mode="bleed"
130
+ style={{width: `100%`}}
131
+ >
132
+ <AvatarGroup
133
+ users={userList.filter((u) => assignees.includes(u.id))}
134
+ />
135
+ </Button>
136
+ )}
137
+ </Popover>
138
+ )
139
+ }
@@ -0,0 +1,43 @@
1
+ import {Card} from '@sanity/ui'
2
+ import React, {useCallback} from 'react'
3
+ import type {ArrayOfPrimitivesInputProps} from 'sanity'
4
+ import {setIfMissing, insert, unset} from 'sanity'
5
+ import {UserSelectMenu, useProjectUsers} from 'sanity-plugin-utils'
6
+
7
+ export default function UserSelectInput(props: ArrayOfPrimitivesInputProps) {
8
+ const {value = [], onChange} = props
9
+ const userList = useProjectUsers()
10
+
11
+ const onAssigneeAdd = useCallback(
12
+ (userId: string) => {
13
+ onChange([setIfMissing([]), insert([userId], `after`, [-1])])
14
+ },
15
+ [onChange]
16
+ )
17
+
18
+ const onAssigneeRemove = useCallback(
19
+ (userId: string) => {
20
+ const userIdIndex = value.findIndex((v) => v === userId)
21
+
22
+ onChange(unset([userIdIndex]))
23
+ },
24
+ [onChange, value]
25
+ )
26
+
27
+ const onAssigneesClear = useCallback(() => {
28
+ onChange(unset())
29
+ }, [onChange])
30
+
31
+ return (
32
+ <Card border radius={3} padding={1}>
33
+ <UserSelectMenu
34
+ open
35
+ value={value as string[]}
36
+ userList={userList}
37
+ onAdd={onAssigneeAdd}
38
+ onClear={onAssigneesClear}
39
+ onRemove={onAssigneeRemove}
40
+ />
41
+ </Card>
42
+ )
43
+ }
@@ -0,0 +1,228 @@
1
+ import React from 'react'
2
+ import {
3
+ Flex,
4
+ Card,
5
+ Box,
6
+ Grid,
7
+ Spinner,
8
+ Label,
9
+ useToast,
10
+ Container,
11
+ useTheme,
12
+ Button,
13
+ } from '@sanity/ui'
14
+ import {Feedback, useProjectUsers} from 'sanity-plugin-utils'
15
+ import {Tool, useClient} from 'sanity'
16
+ import {
17
+ DragDropContext,
18
+ Droppable,
19
+ Draggable,
20
+ DropResult,
21
+ } from 'react-beautiful-dnd'
22
+
23
+ import {SanityDocumentWithMetadata, State} from '../types'
24
+ import {DocumentCard} from './DocumentCard'
25
+ import Mutate from './Mutate'
26
+ import {useWorkflowDocuments} from '../hooks/useWorkflowDocuments'
27
+
28
+ function filterItemsByState(
29
+ items: SanityDocumentWithMetadata[],
30
+ stateId: string
31
+ ) {
32
+ return items.filter((item) => item?._metadata?.state === stateId)
33
+ }
34
+
35
+ type WorkflowToolOptions = {
36
+ schemaTypes: string[]
37
+ states: State[]
38
+ }
39
+
40
+ type WorkflowToolProps = {
41
+ tool: Tool<WorkflowToolOptions>
42
+ }
43
+
44
+ type MutateProps = {
45
+ _id: string
46
+ _type: string
47
+ state: State
48
+ documentId: string
49
+ }
50
+
51
+ export default function WorkflowTool(props: WorkflowToolProps) {
52
+ const {schemaTypes = [], states = []} = props?.tool?.options ?? {}
53
+
54
+ const [mutatingDocs, setMutatingDocs] = React.useState<MutateProps[]>([])
55
+ const mutationFinished = React.useCallback((documentId: string) => {
56
+ setMutatingDocs((docs) => docs.filter((doc) => doc._id !== documentId))
57
+ }, [])
58
+
59
+ const client = useClient()
60
+ const toast = useToast()
61
+
62
+ const isDarkMode = useTheme().sanity.color.dark
63
+ const defaultCardTone = isDarkMode ? 'default' : 'transparent'
64
+
65
+ const userList = useProjectUsers() || []
66
+ const {workflowData, operations} = useWorkflowDocuments(schemaTypes)
67
+
68
+ // Data to display in cards
69
+ const {data, loading, error} = workflowData
70
+
71
+ // Operations to perform on cards
72
+ const {move} = operations
73
+
74
+ const documentsWithoutMetadataIds = data
75
+ .filter((doc) => !doc._metadata)
76
+ .map((d) => d._id.replace(`drafts.`, ``))
77
+
78
+ const importDocuments = React.useCallback(async (ids: string[]) => {
79
+ toast.push({
80
+ title: 'Importing documents',
81
+ status: 'info',
82
+ })
83
+
84
+ const tx = ids.reduce((item, documentId) => {
85
+ return item.createOrReplace({
86
+ _id: `workflow-metadata.${documentId}`,
87
+ _type: 'workflow.metadata',
88
+ state: states[0].id,
89
+ documentId,
90
+ })
91
+ }, client.transaction())
92
+
93
+ await tx.commit()
94
+
95
+ toast.push({
96
+ title: 'Imported documents',
97
+ status: 'success',
98
+ })
99
+ }, [])
100
+
101
+ const handleDragEnd = React.useCallback(
102
+ (result: DropResult) => {
103
+ const {draggableId, source, destination} = result
104
+ console.log(
105
+ `sending ${draggableId} from ${source.droppableId} to ${destination?.droppableId}`
106
+ )
107
+
108
+ if (!destination || destination.droppableId === source.droppableId) {
109
+ return
110
+ }
111
+
112
+ // The list of mutating docs is how we un/publish documents
113
+ const mutatingDoc = move(draggableId, destination, states)
114
+
115
+ if (mutatingDoc) {
116
+ // @ts-ignore
117
+ // @todo not sure if these types should be updated. will documentId every be undefined here?
118
+ setMutatingDocs((current) => [...current, mutatingDoc])
119
+ }
120
+ },
121
+ [move, states]
122
+ )
123
+
124
+ if (!states?.length) {
125
+ return (
126
+ <Container width={1} padding={5}>
127
+ <Feedback
128
+ tone="caution"
129
+ title="Plugin options error"
130
+ description="No States defined in plugin config"
131
+ />
132
+ </Container>
133
+ )
134
+ }
135
+
136
+ if (error) {
137
+ return (
138
+ <Container width={1} padding={5}>
139
+ <Feedback tone="critical" title="Error with query" />
140
+ </Container>
141
+ )
142
+ }
143
+
144
+ return (
145
+ <>
146
+ {mutatingDocs.length ? (
147
+ <div style={{position: `absolute`, bottom: 0, background: 'red'}}>
148
+ {mutatingDocs.map((mutate) => (
149
+ <Mutate
150
+ key={mutate._id}
151
+ {...mutate}
152
+ onComplete={mutationFinished}
153
+ />
154
+ ))}
155
+ </div>
156
+ ) : null}
157
+ {documentsWithoutMetadataIds.length > 0 && (
158
+ <Box padding={5}>
159
+ <Card border padding={3} tone="caution">
160
+ <Flex align="center" justify="center">
161
+ <Button
162
+ onClick={() => importDocuments(documentsWithoutMetadataIds)}
163
+ >
164
+ Import {documentsWithoutMetadataIds.length} Missing{' '}
165
+ {documentsWithoutMetadataIds.length === 1
166
+ ? `Document`
167
+ : `Documents`}{' '}
168
+ into Workflow
169
+ </Button>
170
+ </Flex>
171
+ </Card>
172
+ </Box>
173
+ )}
174
+ <DragDropContext onDragEnd={handleDragEnd}>
175
+ <Grid columns={states.length} height="fill">
176
+ {states.map((state: State, stateIndex: number) => (
177
+ <Card key={state.id} borderLeft={stateIndex > 0}>
178
+ <Card paddingY={4} padding={3} style={{pointerEvents: `none`}}>
179
+ <Label>{state.title}</Label>
180
+ </Card>
181
+ <Droppable droppableId={state.id}>
182
+ {(provided, snapshot) => (
183
+ <Card
184
+ ref={provided.innerRef}
185
+ tone={snapshot.isDraggingOver ? `primary` : defaultCardTone}
186
+ height="fill"
187
+ >
188
+ {loading ? (
189
+ <Flex padding={5} align="center" justify="center">
190
+ <Spinner muted />
191
+ </Flex>
192
+ ) : null}
193
+
194
+ {data.length > 0 &&
195
+ filterItemsByState(data, state.id).map(
196
+ (item, itemIndex) => (
197
+ // The metadata's documentId is always the published one
198
+ <Draggable
199
+ key={item?._metadata?.documentId as string}
200
+ draggableId={item?._metadata?.documentId as string}
201
+ index={itemIndex}
202
+ >
203
+ {(draggableProvided, draggableSnapshot) => (
204
+ <div
205
+ ref={draggableProvided.innerRef}
206
+ {...draggableProvided.draggableProps}
207
+ {...draggableProvided.dragHandleProps}
208
+ >
209
+ <DocumentCard
210
+ isDragging={draggableSnapshot.isDragging}
211
+ item={item}
212
+ userList={userList}
213
+ />
214
+ </div>
215
+ )}
216
+ </Draggable>
217
+ )
218
+ )}
219
+ </Card>
220
+ )}
221
+ </Droppable>
222
+ </Card>
223
+ ))}
224
+ </Grid>
225
+ </DragDropContext>
226
+ </>
227
+ )
228
+ }