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,76 @@
1
1
  import React from 'react'
2
2
  import {useListeningQuery} from 'sanity-plugin-utils'
3
3
  import {useToast} from '@sanity/ui'
4
- import {SanityDocumentLike, useClient} from 'sanity'
4
+ import {useClient} from 'sanity'
5
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
- }
6
+ import groq from 'groq'
7
+
8
+ import {SanityDocumentWithMetadata, State} from '../types'
9
+ import {API_VERSION} from '../constants'
10
+
11
+ const QUERY = groq`*[_type == "workflow.metadata"]|order(orderRank){
12
+ "_metadata": {
13
+ _rev,
14
+ assignees,
15
+ documentId,
16
+ state,
17
+ orderRank
18
+ },
19
+ ...(
20
+ *[_id in [^.documentId, "drafts." + ^.documentId]]|order(_updatedAt)[0]{
21
+ _id,
22
+ _type,
23
+ _rev,
24
+ _updatedAt
25
+ }
26
+ )
27
+ }[defined(_id)]`
12
28
 
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
+ type WorkflowDocuments = {
30
+ workflowData: {
31
+ data: SanityDocumentWithMetadata[]
32
+ loading: boolean
33
+ error: boolean
34
+ }
35
+ operations: {
36
+ move: (
37
+ draggedId: string,
38
+ destination: DraggableLocation,
39
+ states: State[],
40
+ newOrder: string
41
+ ) => void
42
+ }
29
43
  }
30
44
 
31
- export function useWorkflowDocuments(schemaTypes: string[]) {
45
+ export function useWorkflowDocuments(schemaTypes: string[]): WorkflowDocuments {
32
46
  const toast = useToast()
33
- const client = useClient()
47
+ const client = useClient({apiVersion: API_VERSION})
48
+
49
+ // Get and listen to changes on documents + workflow metadata documents
50
+ const {data, loading, error} = useListeningQuery<
51
+ SanityDocumentWithMetadata[]
52
+ >(QUERY, {
53
+ params: {schemaTypes},
54
+ initialValue: [],
55
+ })
56
+
34
57
  const [localDocuments, setLocalDocuments] = React.useState<
35
58
  SanityDocumentWithMetadata[]
36
59
  >([])
37
60
 
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
61
  React.useEffect(() => {
49
62
  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)
63
+ setLocalDocuments(data)
81
64
  }
82
65
  }, [data])
83
66
 
84
67
  const move = React.useCallback(
85
- (draggedId: string, destination: DraggableLocation, states: State[]) => {
68
+ (
69
+ draggedId: string,
70
+ destination: DraggableLocation,
71
+ states: State[],
72
+ newOrder: string
73
+ ) => {
86
74
  // Optimistic update
87
75
  const currentLocalData = localDocuments
88
76
  const newLocalDocuments = localDocuments.map((item) => {
@@ -92,6 +80,12 @@ export function useWorkflowDocuments(schemaTypes: string[]) {
92
80
  _metadata: {
93
81
  ...item._metadata,
94
82
  state: destination.droppableId,
83
+ orderRank: newOrder,
84
+ // This value won't be written to the document
85
+ // It's done so that un/publish operations don't happen twice
86
+ // Because a moved document's card will update once optimistically
87
+ // and then again when the document is updated
88
+ optimistic: true,
95
89
  },
96
90
  }
97
91
  }
@@ -133,12 +127,11 @@ export function useWorkflowDocuments(schemaTypes: string[]) {
133
127
  client
134
128
  .patch(`workflow-metadata.${documentId}`)
135
129
  .ifRevisionId(_rev as string)
136
- .set({state: newStateId})
130
+ .set({state: newStateId, orderRank: newOrder})
137
131
  .commit()
138
132
  .then(() => {
139
133
  return toast.push({
140
134
  title: `Moved to "${newState?.title ?? newStateId}"`,
141
- description: documentId,
142
135
  status: 'success',
143
136
  })
144
137
  })
@@ -148,7 +141,6 @@ export function useWorkflowDocuments(schemaTypes: string[]) {
148
141
 
149
142
  return toast.push({
150
143
  title: `Failed to move to "${newState?.title ?? newStateId}"`,
151
- description: documentId,
152
144
  status: 'error',
153
145
  })
154
146
  })
@@ -1,4 +1,3 @@
1
- import React from 'react'
2
1
  import {useListeningQuery} from 'sanity-plugin-utils'
3
2
 
4
3
  import {Metadata, State} from '../types'
package/src/index.ts CHANGED
@@ -1,40 +1,15 @@
1
- import {definePlugin} from 'sanity'
2
- import {CheckmarkIcon, SplitVerticalIcon} from '@sanity/icons'
1
+ import {definePlugin, DocumentActionProps} from 'sanity'
3
2
 
4
- import WorkflowTool from './components/WorkflowTool'
3
+ import {DEFAULT_CONFIG} from './constants'
5
4
  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
- }
5
+ import {workflowTool} from './tools'
6
+ import metadata from './schema/workflow/workflow.metadata'
7
+ import {AssignWorkflow} from './actions/AssignWorkflow'
8
+ import {BeginWorkflow} from './actions/BeginWorkflow'
9
+ import {CompleteWorkflow} from './actions/CompleteWorkflow'
10
+ import {AssigneesBadge} from './badges/AssigneesBadge'
11
+ import {StateBadge} from './badges/StateBadge'
12
+ import {UpdateWorkflow} from './actions/UpdateWorkflow'
38
13
 
39
14
  export const workflow = definePlugin<WorkflowConfig>(
40
15
  (config = DEFAULT_CONFIG) => {
@@ -49,26 +24,29 @@ export const workflow = definePlugin<WorkflowConfig>(
49
24
  schema: {
50
25
  types: [metadata(states)],
51
26
  },
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
- // },
27
+ // TODO: Remove 'workflow.metadata' from list of new document types
28
+ // ...
63
29
  document: {
64
30
  actions: (prev, context) => {
65
31
  if (!schemaTypes.includes(context.schemaType)) {
66
32
  return prev
67
33
  }
68
34
 
35
+ // TODO: Augment 'publish' and 'unpublish' to be disabled if a document IS in Workflow
36
+ // This would be best done with a listening query here, but we don't have access to documentStore
37
+
38
+ // TODO: Performance improvements:
39
+ // Each of these actions registers their own listener!
40
+ // One should probably be responsible for listening and storing the response in context
41
+
69
42
  return [
70
- (props) => PromoteAction(props, states),
71
- (props) => DemoteAction(props, states),
43
+ (props) => BeginWorkflow(props, states),
44
+ (props) => AssignWorkflow(props, states),
45
+ ...states.map(
46
+ (state) => (props: DocumentActionProps) =>
47
+ UpdateWorkflow(props, states, state)
48
+ ),
49
+ (props) => CompleteWorkflow(props, states),
72
50
  ...prev,
73
51
  ]
74
52
  },
@@ -77,18 +55,20 @@ export const workflow = definePlugin<WorkflowConfig>(
77
55
  return prev
78
56
  }
79
57
 
80
- return [(props) => StateBadge(props, states), ...prev]
58
+ const {documentId, currentUser} = context
59
+
60
+ if (!documentId) {
61
+ return prev
62
+ }
63
+
64
+ return [
65
+ () => StateBadge(states, documentId),
66
+ () => AssigneesBadge(states, documentId, currentUser),
67
+ ...prev,
68
+ ]
81
69
  },
82
70
  },
83
- tools: [
84
- {
85
- name: 'workflow',
86
- title: 'Workflow',
87
- component: WorkflowTool,
88
- icon: SplitVerticalIcon,
89
- options: {schemaTypes, states},
90
- },
91
- ],
71
+ tools: [workflowTool({schemaTypes, states})],
92
72
  }
93
73
  }
94
74
  )
@@ -0,0 +1,68 @@
1
+ import {defineType, defineField} from 'sanity'
2
+
3
+ import Field from '../../components/DocumentCard/Field'
4
+ import UserAssignmentInput from '../../components/UserAssignmentInput'
5
+ import {API_VERSION} from '../../constants'
6
+ import initialRank from '../../helpers/initialRank'
7
+ import {State} from '../../types'
8
+
9
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
10
+ export default (states: State[]) =>
11
+ defineType({
12
+ type: 'document',
13
+ name: 'workflow.metadata',
14
+ title: 'Workflow metadata',
15
+ liveEdit: true,
16
+ fields: [
17
+ defineField({
18
+ name: 'state',
19
+ description: `The current "State" of the document. Field is read only as changing it would not fire the state's "operation" setting. These are fired in the Document Actions and in the custom Tool.`,
20
+ readOnly: true,
21
+ type: 'string',
22
+ options: {
23
+ list: states.length
24
+ ? states.map((state) => ({
25
+ value: state.id,
26
+ title: state.title,
27
+ }))
28
+ : [],
29
+ layout: 'radio',
30
+ },
31
+ }),
32
+ defineField({
33
+ name: 'documentId',
34
+ title: 'Document ID',
35
+ description:
36
+ 'Used to help identify the target document that this metadata is tracking state for.',
37
+ type: 'string',
38
+ readOnly: true,
39
+ components: {
40
+ input: Field,
41
+ },
42
+ }),
43
+ defineField({
44
+ name: 'orderRank',
45
+ description: 'Used to maintain order position of cards in the Tool.',
46
+ type: 'string',
47
+ readOnly: true,
48
+ initialValue: async (p, {getClient}) => {
49
+ const lastDocOrderRank = await getClient({
50
+ apiVersion: API_VERSION,
51
+ }).fetch(`*[_type == $type]|order(@[$order] desc)[0][$order]`, {
52
+ order: `orderRank`,
53
+ type: `workflow.metadata`,
54
+ })
55
+
56
+ return initialRank(lastDocOrderRank)
57
+ },
58
+ }),
59
+ defineField({
60
+ type: 'array',
61
+ name: 'assignees',
62
+ of: [{type: 'string'}],
63
+ components: {
64
+ input: UserAssignmentInput,
65
+ },
66
+ }),
67
+ ],
68
+ })
@@ -0,0 +1,15 @@
1
+ import {Tool} from 'sanity'
2
+ import {SplitVerticalIcon} from '@sanity/icons'
3
+
4
+ import WorkflowTool from '../components/WorkflowTool'
5
+ import {WorkflowConfig} from '../types'
6
+
7
+ export type WorkflowToolConfig = (options: WorkflowConfig) => Tool
8
+
9
+ export const workflowTool: WorkflowToolConfig = (options: WorkflowConfig) => ({
10
+ name: 'workflow',
11
+ title: 'Workflow',
12
+ component: WorkflowTool,
13
+ icon: SplitVerticalIcon,
14
+ options,
15
+ })
@@ -1,20 +1,36 @@
1
- import React from 'react'
2
1
  import {SanityDocumentLike} from 'sanity'
3
2
 
3
+ // export type Operation = 'publish' | 'unpublish'
4
+
4
5
  export type State = {
5
6
  id: string
7
+ transitions: string[]
6
8
  title: string
7
- operation?: 'publish' | 'unpublish' | null
8
- // From badge props
9
+ // operation?: Operation
10
+ roles?: string[]
11
+ requireAssignment?: boolean
12
+ // From document badges
9
13
  color?: 'primary' | 'success' | 'warning' | 'danger'
10
- icon?: React.ReactNode | React.ComponentType
11
14
  }
12
15
 
16
+ export type StateCheck<Id, States> = {
17
+ id: Id
18
+ // Transitions is an array of State ids
19
+ transitions?: States extends {id: infer Id2}[] ? Id2[] : never
20
+ } & State
21
+
13
22
  export type WorkflowConfig = {
14
23
  schemaTypes: string[]
15
24
  states?: State[]
16
25
  }
17
26
 
27
+ export function defineStates<
28
+ Id extends string,
29
+ States extends StateCheck<Id, States>[]
30
+ >(states: States): States {
31
+ return states
32
+ }
33
+
18
34
  export type User = {
19
35
  createdAt: string
20
36
  displayName: string
@@ -43,8 +59,13 @@ export type Metadata = SanityDocumentLike & {
43
59
  assignees: string[]
44
60
  documentId: string
45
61
  state: string
62
+ orderRank: string
46
63
  }
47
64
 
48
- export type SanityDocumentWithMetadata = SanityDocumentLike & {
49
- _metadata: Metadata | null
65
+ export type SanityDocumentWithMetadata = {
66
+ _metadata: Metadata
67
+ _id: string
68
+ _type: string
69
+ _rev: string
70
+ _updatedAt: string
50
71
  }
@@ -1,62 +0,0 @@
1
- import {ArrowLeftIcon} from '@sanity/icons'
2
- import {useToast} from '@sanity/ui'
3
- import {DocumentActionProps, useClient} from 'sanity'
4
- import {useWorkflowMetadata} from '../hooks/useWorkflowMetadata'
5
-
6
- import {State} from '../types'
7
-
8
- export function DemoteAction(props: DocumentActionProps, states: State[]) {
9
- const {id} = props
10
- const {data, loading, error} = useWorkflowMetadata(id, states)
11
- const {state} = data
12
- const client = useClient()
13
- const toast = useToast()
14
-
15
- if (loading || error) {
16
- if (error) {
17
- console.error(error)
18
- }
19
-
20
- return null
21
- }
22
-
23
- if (!state) {
24
- return null
25
- }
26
-
27
- const onHandle = (documentId: string, newState: State) => {
28
- client
29
- .patch(`workflow-metadata.${documentId}`)
30
- .set({state: newState.id})
31
- .commit()
32
- .then(() => {
33
- props.onComplete()
34
- toast.push({
35
- status: 'success',
36
- title: `Document demoted to ${newState.title}`,
37
- })
38
- })
39
- .catch((err) => {
40
- props.onComplete()
41
- console.error(err)
42
- toast.push({
43
- status: 'error',
44
- title: `Document demotion failed`,
45
- })
46
- })
47
- }
48
-
49
- const currentStateIndex = states.findIndex((s) => s.id === state.id)
50
- const prevState = states[currentStateIndex - 1]
51
-
52
- if (!prevState) {
53
- return null
54
- }
55
-
56
- return {
57
- icon: ArrowLeftIcon,
58
- label: `Demote`,
59
- title: `Demote State to "${prevState.title}"`,
60
- onHandle: () => onHandle(id, prevState),
61
- }
62
- }
@@ -1,62 +0,0 @@
1
- import {ArrowRightIcon} from '@sanity/icons'
2
- import {useToast} from '@sanity/ui'
3
- import {DocumentActionProps, useClient} from 'sanity'
4
- import {useWorkflowMetadata} from '../hooks/useWorkflowMetadata'
5
-
6
- import {State} from '../types'
7
-
8
- export function PromoteAction(props: DocumentActionProps, states: State[]) {
9
- const {id} = props
10
- const {data, loading, error} = useWorkflowMetadata(id, states)
11
- const {state} = data
12
- const client = useClient()
13
- const toast = useToast()
14
-
15
- if (loading || error) {
16
- if (error) {
17
- console.error(error)
18
- }
19
-
20
- return null
21
- }
22
-
23
- if (!state) {
24
- return null
25
- }
26
-
27
- const onHandle = (documentId: string, newState: State) => {
28
- client
29
- .patch(`workflow-metadata.${documentId}`)
30
- .set({state: newState.id})
31
- .commit()
32
- .then(() => {
33
- props.onComplete()
34
- toast.push({
35
- status: 'success',
36
- title: `Document promoted to ${newState.title}`,
37
- })
38
- })
39
- .catch((err) => {
40
- props.onComplete()
41
- console.error(err)
42
- toast.push({
43
- status: 'error',
44
- title: `Document promotion failed`,
45
- })
46
- })
47
- }
48
-
49
- const currentStateIndex = states.findIndex((s) => s.id === state.id)
50
- const nextState = states[currentStateIndex + 1]
51
-
52
- if (!nextState) {
53
- return null
54
- }
55
-
56
- return {
57
- icon: ArrowRightIcon,
58
- label: `Promote`,
59
- title: `Promote State to "${nextState.title}"`,
60
- onHandle: () => onHandle(id, nextState),
61
- }
62
- }
@@ -1,54 +0,0 @@
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
- }