sanity-plugin-workflow 1.0.0-beta.1 → 1.0.0-beta.3

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.
Files changed (49) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +71 -12
  3. package/lib/{src/index.d.ts → index.d.ts} +3 -3
  4. package/lib/index.esm.js +1691 -1
  5. package/lib/index.esm.js.map +1 -1
  6. package/lib/index.js +1704 -1
  7. package/lib/index.js.map +1 -1
  8. package/package.json +48 -38
  9. package/src/actions/AssignWorkflow.tsx +48 -0
  10. package/src/actions/BeginWorkflow.tsx +68 -0
  11. package/src/actions/CompleteWorkflow.tsx +41 -0
  12. package/src/actions/RequestReviewAction.js +1 -7
  13. package/src/actions/UpdateWorkflow.tsx +142 -0
  14. package/src/badges/AssigneesBadge.tsx +52 -0
  15. package/src/badges/{index.tsx → StateBadge.tsx} +4 -8
  16. package/src/components/DocumentCard/AvatarGroup.tsx +12 -8
  17. package/src/components/DocumentCard/CompleteButton.tsx +53 -0
  18. package/src/components/DocumentCard/EditButton.tsx +3 -2
  19. package/src/components/DocumentCard/Field.tsx +38 -0
  20. package/src/components/DocumentCard/ValidationStatus.tsx +37 -0
  21. package/src/components/DocumentCard/core/DraftStatus.tsx +32 -0
  22. package/src/components/DocumentCard/core/PublishedStatus.tsx +32 -0
  23. package/src/components/DocumentCard/core/TimeAgo.tsx +11 -0
  24. package/src/components/DocumentCard/index.tsx +156 -50
  25. package/src/components/Filters.tsx +168 -0
  26. package/src/components/FloatingCard.tsx +29 -0
  27. package/src/components/StateTitle/Status.tsx +27 -0
  28. package/src/components/StateTitle/index.tsx +73 -0
  29. package/src/components/UserAssignment.tsx +57 -75
  30. package/src/components/UserAssignmentInput.tsx +27 -0
  31. package/src/components/UserDisplay.tsx +57 -0
  32. package/src/components/Validators.tsx +196 -0
  33. package/src/components/WorkflowTool.tsx +301 -160
  34. package/src/constants/index.ts +31 -0
  35. package/src/helpers/arraysContainMatchingString.ts +6 -0
  36. package/src/helpers/filterItemsAndSort.ts +39 -0
  37. package/src/helpers/initialRank.ts +13 -0
  38. package/src/hooks/useWorkflowDocuments.tsx +62 -70
  39. package/src/hooks/useWorkflowMetadata.tsx +0 -1
  40. package/src/index.ts +38 -58
  41. package/src/schema/workflow/workflow.metadata.ts +68 -0
  42. package/src/tools/index.ts +15 -0
  43. package/src/types/index.ts +27 -6
  44. package/src/actions/DemoteAction.tsx +0 -62
  45. package/src/actions/PromoteAction.tsx +0 -62
  46. package/src/components/Mutate.tsx +0 -54
  47. package/src/components/StateTimeline.tsx +0 -98
  48. package/src/components/UserSelectInput.tsx +0 -43
  49. package/src/schema/workflow/metadata.ts +0 -38
@@ -1,88 +1,194 @@
1
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'
2
+ import {useEffect, useMemo} from 'react'
3
+ import {Box, Card, CardTone, Flex, Stack, useTheme} from '@sanity/ui'
4
+ import {DragHandleIcon} from '@sanity/icons'
5
+ import {useSchema, SchemaType, useValidationStatus} from 'sanity'
16
6
  import {Preview} from 'sanity'
17
7
 
18
8
  import EditButton from './EditButton'
19
- import {SanityDocumentWithMetadata, User} from '../../types'
20
- import AvatarGroup from './AvatarGroup'
21
- import UserAssignment from '../UserAssignment'
9
+ import {SanityDocumentWithMetadata, State, User} from '../../types'
10
+ import UserDisplay from '../UserDisplay'
11
+ import {DraftStatus} from './core/DraftStatus'
12
+ import {PublishedStatus} from './core/PublishedStatus'
13
+ import {ValidationStatus} from './ValidationStatus'
14
+ import CompleteButton from './CompleteButton'
22
15
 
23
16
  type DocumentCardProps = {
24
- userList: User[]
17
+ isDragDisabled: boolean
18
+ userRoleCanDrop: boolean
25
19
  isDragging: boolean
26
20
  item: SanityDocumentWithMetadata
21
+ states: State[]
22
+ toggleInvalidDocumentId: (
23
+ documentId: string,
24
+ action: 'ADD' | 'REMOVE'
25
+ ) => void
26
+ userList: User[]
27
27
  }
28
28
 
29
29
  export function DocumentCard(props: DocumentCardProps) {
30
- const {userList, isDragging, item} = props
30
+ const {
31
+ isDragDisabled,
32
+ userRoleCanDrop,
33
+ isDragging,
34
+ item,
35
+ states,
36
+ toggleInvalidDocumentId,
37
+ userList,
38
+ } = props
31
39
  const {assignees = [], documentId} = item._metadata ?? {}
32
40
  const schema = useSchema()
33
41
 
42
+ // Perform document operations after State changes
43
+ // If State has changed and the document needs to be un/published
44
+ // This functionality was deemed too dangerous / unexpected
45
+ // Revisit with improved UX
46
+ // const currentState = useMemo(
47
+ // () => states.find((state) => state.id === item._metadata?.state),
48
+ // [states, item]
49
+ // )
50
+ // const ops = useDocumentOperation(documentId ?? ``, item._type)
51
+ // const toast = useToast()
52
+
53
+ // useEffect(() => {
54
+ // const isDraft = item._id.startsWith('drafts.')
55
+
56
+ // if (isDraft && currentState?.operation === 'publish' && !item?._metadata?.optimistic) {
57
+ // if (!ops.publish.disabled) {
58
+ // ops.publish.execute()
59
+ // toast.push({
60
+ // title: 'Published Document',
61
+ // description: documentId,
62
+ // status: 'success',
63
+ // })
64
+ // }
65
+ // } else if (
66
+ // !isDraft &&
67
+ // currentState?.operation === 'unpublish' &&
68
+ // !item?._metadata?.optimistic
69
+ // ) {
70
+ // if (!ops.unpublish.disabled) {
71
+ // ops.unpublish.execute()
72
+ // toast.push({
73
+ // title: 'Unpublished Document',
74
+ // description: documentId,
75
+ // status: 'success',
76
+ // })
77
+ // }
78
+ // }
79
+ // }, [currentState, documentId, item, ops, toast])
80
+
34
81
  const isDarkMode = useTheme().sanity.color.dark
35
- const defaultCardTone = isDarkMode ? 'transparent' : 'default'
82
+ const defaultCardTone = isDarkMode ? `transparent` : `default`
83
+ const {validation = [], isValidating} = useValidationStatus(
84
+ documentId ?? ``,
85
+ item._type
86
+ )
36
87
 
37
- // Open/close handler
38
- // const [popoverRef, setPopoverRef] = useState(null)
39
- // const [openId, setOpenId] = useState<string | undefined>(``)
88
+ const cardTone = useMemo(() => {
89
+ let tone: CardTone = defaultCardTone
40
90
 
41
- // useClickOutside(() => setOpenId(``), [popoverRef])
91
+ if (!userRoleCanDrop) return isDarkMode ? `default` : `transparent`
92
+ if (!documentId) return tone
93
+ if (isDragging) tone = `positive`
42
94
 
43
- // const handleKeyDown = React.useCallback((e) => {
44
- // if (e.key === 'Escape') {
45
- // setOpenId(``)
46
- // }
47
- // }, [])
95
+ if (!isValidating && validation.length > 0) {
96
+ if (validation.some((v) => v.level === 'error')) {
97
+ tone = `critical`
98
+ } else {
99
+ tone = `caution`
100
+ }
101
+ }
102
+
103
+ return tone
104
+ }, [
105
+ isDarkMode,
106
+ userRoleCanDrop,
107
+ defaultCardTone,
108
+ documentId,
109
+ isDragging,
110
+ validation,
111
+ isValidating,
112
+ ])
113
+
114
+ // Update validation status
115
+ // Cannot be done in the above memo because it would set state during render
116
+ useEffect(() => {
117
+ if (!isValidating && validation.length > 0) {
118
+ if (validation.some((v) => v.level === 'error')) {
119
+ toggleInvalidDocumentId(documentId, 'ADD')
120
+ } else {
121
+ toggleInvalidDocumentId(documentId, 'REMOVE')
122
+ }
123
+ } else {
124
+ toggleInvalidDocumentId(documentId, 'REMOVE')
125
+ }
126
+ }, [documentId, isValidating, toggleInvalidDocumentId, validation])
127
+
128
+ const hasError = useMemo(
129
+ () => (isValidating ? false : validation.some((v) => v.level === 'error')),
130
+ [isValidating, validation]
131
+ )
132
+
133
+ const isLastState = useMemo(
134
+ () => states[states.length - 1].id === item._metadata?.state,
135
+ [states, item._metadata.state]
136
+ )
48
137
 
49
138
  return (
50
- <Box paddingY={2} paddingX={3}>
51
- <Card
52
- radius={2}
53
- shadow={isDragging ? 3 : 1}
54
- tone={isDragging ? 'positive' : defaultCardTone}
55
- >
139
+ <Box paddingBottom={3} paddingX={3}>
140
+ <Card radius={2} shadow={isDragging ? 3 : 1} tone={cardTone}>
56
141
  <Stack>
57
142
  <Card
58
143
  borderBottom
59
144
  radius={2}
60
145
  padding={3}
61
146
  paddingLeft={2}
62
- tone="inherit"
147
+ tone={cardTone}
63
148
  style={{pointerEvents: 'none'}}
64
149
  >
65
150
  <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}} />
151
+ <Box flex={1}>
152
+ <Preview
153
+ layout="default"
154
+ value={item}
155
+ schemaType={schema.get(item._type) as SchemaType}
156
+ />
157
+ </Box>
158
+ <Box style={{flexShrink: 0}}>
159
+ {hasError || isDragDisabled ? null : <DragHandleIcon />}
160
+ </Box>
72
161
  </Flex>
73
162
  </Card>
74
163
 
75
164
  <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}
165
+ <Flex align="center" justify="space-between" gap={3}>
166
+ <Box flex={1}>
167
+ {documentId && (
168
+ <UserDisplay
169
+ userList={userList}
170
+ assignees={assignees}
171
+ documentId={documentId}
172
+ disabled={!userRoleCanDrop}
173
+ />
174
+ )}
175
+ </Box>
176
+ {validation.length > 0 ? (
177
+ <ValidationStatus validation={validation} />
178
+ ) : null}
179
+ <DraftStatus document={item} />
180
+ <PublishedStatus document={item} />
181
+ <EditButton
182
+ id={item._id}
183
+ type={item._type}
184
+ disabled={!userRoleCanDrop}
185
+ />
186
+ {isLastState ? (
187
+ <CompleteButton
81
188
  documentId={documentId}
189
+ disabled={!userRoleCanDrop}
82
190
  />
83
- )}
84
-
85
- <EditButton id={item._id} type={item._type} />
191
+ ) : null}
86
192
  </Flex>
87
193
  </Card>
88
194
  </Stack>
@@ -0,0 +1,168 @@
1
+ import {MenuButton, Menu, Flex, Card, Button} from '@sanity/ui'
2
+ import {useCurrentUser, UserAvatar, useSchema} from 'sanity'
3
+ import {UserIcon, ResetIcon} from '@sanity/icons'
4
+ import {UserExtended, UserSelectMenu} from 'sanity-plugin-utils'
5
+ import {useCallback} from 'react'
6
+
7
+ type FiltersProps = {
8
+ uniqueAssignedUsers: UserExtended[]
9
+ selectedUserIds: string[]
10
+ schemaTypes: string[]
11
+ selectedSchemaTypes: string[]
12
+ toggleSelectedUser: (userId: string) => void
13
+ resetSelectedUsers: () => void
14
+ toggleSelectedSchemaType: (schemaType: string) => void
15
+ }
16
+
17
+ export default function Filters(props: FiltersProps) {
18
+ const {
19
+ uniqueAssignedUsers = [],
20
+ selectedUserIds,
21
+ schemaTypes,
22
+ selectedSchemaTypes,
23
+ toggleSelectedUser,
24
+ resetSelectedUsers,
25
+ toggleSelectedSchemaType,
26
+ } = props
27
+
28
+ const currentUser = useCurrentUser()
29
+ const schema = useSchema()
30
+
31
+ const onAdd = useCallback(
32
+ (id: string) => {
33
+ if (!selectedUserIds.includes(id)) {
34
+ toggleSelectedUser(id)
35
+ }
36
+ },
37
+ [selectedUserIds, toggleSelectedUser]
38
+ )
39
+
40
+ const onRemove = useCallback(
41
+ (id: string) => {
42
+ if (selectedUserIds.includes(id)) {
43
+ toggleSelectedUser(id)
44
+ }
45
+ },
46
+ [selectedUserIds, toggleSelectedUser]
47
+ )
48
+
49
+ const onClear = useCallback(() => {
50
+ resetSelectedUsers()
51
+ }, [resetSelectedUsers])
52
+
53
+ if (uniqueAssignedUsers.length === 0 && schemaTypes.length < 2) {
54
+ return null
55
+ }
56
+
57
+ const meInUniqueAssignees =
58
+ currentUser?.id && uniqueAssignedUsers.find((u) => u.id === currentUser.id)
59
+ const uniqueAssigneesNotMe = uniqueAssignedUsers.filter(
60
+ (u) => u.id !== currentUser?.id
61
+ )
62
+
63
+ return (
64
+ <Card tone="primary" padding={2} borderBottom style={{overflowX: 'hidden'}}>
65
+ <Flex align="center">
66
+ <Flex align="center" gap={1} flex={1}>
67
+ {uniqueAssignedUsers.length > 5 ? (
68
+ <Card tone="default">
69
+ <MenuButton
70
+ button={
71
+ <Button
72
+ text="Filter Assignees"
73
+ tone="primary"
74
+ icon={UserIcon}
75
+ />
76
+ }
77
+ id="user-filters"
78
+ menu={
79
+ <Menu>
80
+ <UserSelectMenu
81
+ value={selectedUserIds}
82
+ userList={uniqueAssignedUsers}
83
+ onAdd={onAdd}
84
+ onRemove={onRemove}
85
+ onClear={onClear}
86
+ labels={{
87
+ addMe: 'Filter mine',
88
+ removeMe: 'Clear mine',
89
+ clear: 'Clear filters',
90
+ }}
91
+ />
92
+ </Menu>
93
+ }
94
+ popover={{portal: true}}
95
+ />
96
+ </Card>
97
+ ) : (
98
+ <>
99
+ {meInUniqueAssignees ? (
100
+ <>
101
+ <Button
102
+ padding={0}
103
+ mode={
104
+ selectedUserIds.includes(currentUser.id)
105
+ ? `default`
106
+ : `bleed`
107
+ }
108
+ onClick={() => toggleSelectedUser(currentUser.id)}
109
+ >
110
+ <Flex padding={1} align="center" justify="center">
111
+ <UserAvatar user={currentUser.id} size={1} withTooltip />
112
+ </Flex>
113
+ </Button>
114
+ <Card borderRight style={{height: 30}} tone="inherit" />
115
+ </>
116
+ ) : null}
117
+ {uniqueAssigneesNotMe.map((user) => (
118
+ <Button
119
+ key={user.id}
120
+ padding={0}
121
+ mode={selectedUserIds.includes(user.id) ? `default` : `bleed`}
122
+ onClick={() => toggleSelectedUser(user.id)}
123
+ >
124
+ <Flex padding={1} align="center" justify="center">
125
+ <UserAvatar user={user} size={1} withTooltip />
126
+ </Flex>
127
+ </Button>
128
+ ))}
129
+
130
+ {selectedUserIds.length > 0 ? (
131
+ <Button
132
+ text="Clear"
133
+ onClick={resetSelectedUsers}
134
+ mode="ghost"
135
+ icon={ResetIcon}
136
+ />
137
+ ) : null}
138
+ </>
139
+ )}
140
+ </Flex>
141
+
142
+ {schemaTypes.length > 0 ? (
143
+ <Flex align="center" gap={1}>
144
+ {schemaTypes.map((typeName) => {
145
+ const schemaType = schema.get(typeName)
146
+
147
+ if (!schemaType) {
148
+ return null
149
+ }
150
+
151
+ return (
152
+ <Button
153
+ key={typeName}
154
+ text={schemaType?.title ?? typeName}
155
+ icon={schemaType?.icon ?? undefined}
156
+ mode={
157
+ selectedSchemaTypes.includes(typeName) ? `default` : `ghost`
158
+ }
159
+ onClick={() => toggleSelectedSchemaType(typeName)}
160
+ />
161
+ )
162
+ })}
163
+ </Flex>
164
+ ) : null}
165
+ </Flex>
166
+ </Card>
167
+ )
168
+ }
@@ -0,0 +1,29 @@
1
+ import {PropsWithChildren} from 'react'
2
+ import styled, {css} from 'styled-components'
3
+ import {Card, Grid} from '@sanity/ui'
4
+ import {motion, AnimatePresence} from 'framer-motion'
5
+
6
+ const StyledFloatingCard = styled(Card)(
7
+ () => css`
8
+ position: fixed;
9
+ bottom: 0;
10
+ left: 0;
11
+ z-index: 1000;
12
+ `
13
+ )
14
+
15
+ export default function FloatingCard({children}: PropsWithChildren) {
16
+ const childrenHaveValues = Array.isArray(children) ? children.some(Boolean) : Boolean(children)
17
+
18
+ return (
19
+ <AnimatePresence>
20
+ {childrenHaveValues ? (
21
+ <motion.div key="floater" initial={{opacity: 0}} animate={{opacity: 1}} exit={{opacity: 0}}>
22
+ <StyledFloatingCard shadow={3} padding={3} margin={3} radius={3}>
23
+ <Grid gap={2}>{children}</Grid>
24
+ </StyledFloatingCard>
25
+ </motion.div>
26
+ ) : null}
27
+ </AnimatePresence>
28
+ )
29
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react'
2
+ import {Box, Text, Tooltip} from '@sanity/ui'
3
+
4
+ type StatusProps = {
5
+ text: string
6
+ icon: React.ComponentType
7
+ }
8
+
9
+ export function Status(props: StatusProps) {
10
+ const {text, icon} = props
11
+ const Icon = icon
12
+
13
+ return (
14
+ <Tooltip
15
+ portal
16
+ content={
17
+ <Box padding={2}>
18
+ <Text size={1}>{text}</Text>
19
+ </Box>
20
+ }
21
+ >
22
+ <Text size={1}>
23
+ <Icon />
24
+ </Text>
25
+ </Tooltip>
26
+ )
27
+ }
@@ -0,0 +1,73 @@
1
+ import {Flex, Card, Badge, BadgeTone} from '@sanity/ui'
2
+ import {InfoOutlineIcon, UserIcon} from '@sanity/icons'
3
+ import styled, {css} from 'styled-components'
4
+
5
+ import {Status} from './Status'
6
+ import {
7
+ // Operation,
8
+ State,
9
+ } from '../../types'
10
+
11
+ type StateTitleProps = {
12
+ state: State
13
+ requireAssignment: boolean
14
+ userRoleCanDrop: boolean
15
+ isDropDisabled: boolean
16
+ draggingFrom: string
17
+ // operation?: Operation
18
+ }
19
+
20
+ const StyledStickyCard = styled(Card)(
21
+ () => css`
22
+ position: sticky;
23
+ top: 0;
24
+ z-index: 1;
25
+ `
26
+ )
27
+
28
+ export default function StateTitle(props: StateTitleProps) {
29
+ const {state, requireAssignment, userRoleCanDrop, isDropDisabled, draggingFrom} = props
30
+
31
+ let tone: BadgeTone = 'default'
32
+ const isSource = draggingFrom === state.id
33
+
34
+ if (draggingFrom) {
35
+ tone = isDropDisabled || isSource ? 'default' : 'positive'
36
+ }
37
+
38
+ return (
39
+ <StyledStickyCard paddingY={4} padding={3} tone="inherit">
40
+ <Flex gap={3} align="center">
41
+ <Badge
42
+ mode={(draggingFrom && !isDropDisabled) || isSource ? 'default' : 'outline'}
43
+ tone={tone}
44
+ muted={!userRoleCanDrop || isDropDisabled}
45
+ >
46
+ {state.title}
47
+ </Badge>
48
+ {userRoleCanDrop ? null : (
49
+ <Status
50
+ text="You do not have permissions to move documents to this State"
51
+ icon={InfoOutlineIcon}
52
+ />
53
+ )}
54
+ {requireAssignment ? (
55
+ <Status
56
+ text="You must be assigned to the document to move documents to this State"
57
+ icon={UserIcon}
58
+ />
59
+ ) : null}
60
+ {/* {operation ? (
61
+ <Status
62
+ text={
63
+ operation === 'publish'
64
+ ? `A document moved to this State will also publish the current Draft`
65
+ : `A document moved to this State will also unpublish the current Published version`
66
+ }
67
+ icon={operation === 'publish' ? PublishIcon : UnpublishIcon}
68
+ />
69
+ ) : null} */}
70
+ </Flex>
71
+ </StyledStickyCard>
72
+ )
73
+ }