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

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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +81 -13
  3. package/lib/{src/index.d.ts → index.d.ts} +4 -3
  4. package/lib/index.esm.js +2106 -1
  5. package/lib/index.esm.js.map +1 -1
  6. package/lib/index.js +2119 -1
  7. package/lib/index.js.map +1 -1
  8. package/package.json +51 -40
  9. package/src/actions/AssignWorkflow.tsx +49 -0
  10. package/src/actions/BeginWorkflow.tsx +63 -0
  11. package/src/actions/CompleteWorkflow.tsx +41 -0
  12. package/src/actions/UpdateWorkflow.tsx +126 -0
  13. package/src/badges/AssigneesBadge.tsx +53 -0
  14. package/src/badges/StateBadge.tsx +28 -0
  15. package/src/components/DocumentCard/AvatarGroup.tsx +12 -8
  16. package/src/components/DocumentCard/CompleteButton.tsx +68 -0
  17. package/src/components/DocumentCard/EditButton.tsx +3 -2
  18. package/src/components/DocumentCard/Field.tsx +38 -0
  19. package/src/components/DocumentCard/Validate.tsx +21 -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 +39 -0
  23. package/src/components/DocumentCard/core/TimeAgo.tsx +11 -0
  24. package/src/components/DocumentCard/index.tsx +177 -68
  25. package/src/components/DocumentList.tsx +169 -0
  26. package/src/components/Filters.tsx +168 -0
  27. package/src/components/FloatingCard.tsx +29 -0
  28. package/src/components/StateTitle/Status.tsx +27 -0
  29. package/src/components/StateTitle/index.tsx +78 -0
  30. package/src/components/UserAssignment.tsx +57 -75
  31. package/src/components/UserAssignmentInput.tsx +27 -0
  32. package/src/components/UserDisplay.tsx +57 -0
  33. package/src/components/Verify.tsx +297 -0
  34. package/src/components/WorkflowContext.tsx +71 -0
  35. package/src/components/WorkflowSignal.tsx +30 -0
  36. package/src/components/WorkflowTool.tsx +373 -162
  37. package/src/constants/index.ts +31 -0
  38. package/src/helpers/arraysContainMatchingString.ts +6 -0
  39. package/src/helpers/filterItemsAndSort.ts +41 -0
  40. package/src/helpers/generateMultipleOrderRanks.ts +80 -0
  41. package/src/helpers/initialRank.ts +13 -0
  42. package/src/hooks/useWorkflowDocuments.tsx +76 -78
  43. package/src/hooks/useWorkflowMetadata.tsx +31 -26
  44. package/src/index.ts +60 -57
  45. package/src/schema/workflow/workflow.metadata.ts +68 -0
  46. package/src/tools/index.ts +15 -0
  47. package/src/types/index.ts +27 -6
  48. package/src/actions/DemoteAction.tsx +0 -62
  49. package/src/actions/PromoteAction.tsx +0 -62
  50. package/src/actions/RequestReviewAction.js +0 -61
  51. package/src/actions/index.js +0 -21
  52. package/src/badges/index.tsx +0 -31
  53. package/src/components/Mutate.tsx +0 -54
  54. package/src/components/StateTimeline.tsx +0 -98
  55. package/src/components/UserSelectInput.tsx +0 -43
  56. package/src/schema/workflow/metadata.ts +0 -38
@@ -0,0 +1,49 @@
1
+ import {UsersIcon} from '@sanity/icons'
2
+ import {useState} from 'react'
3
+ import {DocumentActionProps} from 'sanity'
4
+ import {useProjectUsers} from 'sanity-plugin-utils'
5
+
6
+ import UserAssignment from '../components/UserAssignment'
7
+ import {useWorkflowContext} from '../components/WorkflowContext'
8
+ import {API_VERSION} from '../constants'
9
+
10
+ export function AssignWorkflow(props: DocumentActionProps) {
11
+ const {id} = props
12
+ const {metadata, loading, error} = useWorkflowContext(id)
13
+ const [isDialogOpen, setDialogOpen] = useState(false)
14
+ const userList = useProjectUsers({apiVersion: API_VERSION})
15
+
16
+ if (error) {
17
+ console.error(error)
18
+ }
19
+
20
+ if (!metadata) {
21
+ return null
22
+ }
23
+
24
+ return {
25
+ icon: UsersIcon,
26
+ type: 'dialog',
27
+ disabled: !metadata || loading || error,
28
+ label: `Assign`,
29
+ title: metadata ? null : `Document is not in Workflow`,
30
+ dialog: isDialogOpen && {
31
+ type: 'popover',
32
+ onClose: () => {
33
+ setDialogOpen(false)
34
+ },
35
+ content: (
36
+ <UserAssignment
37
+ userList={userList}
38
+ assignees={
39
+ metadata && metadata.assignees.length > 0 ? metadata.assignees : []
40
+ }
41
+ documentId={id}
42
+ />
43
+ ),
44
+ },
45
+ onHandle: () => {
46
+ setDialogOpen(true)
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,63 @@
1
+ import {SplitVerticalIcon} from '@sanity/icons'
2
+ import {useToast} from '@sanity/ui'
3
+ import {LexoRank} from 'lexorank'
4
+ import {useCallback, useState} from 'react'
5
+ import {DocumentActionProps, useClient} from 'sanity'
6
+
7
+ import {useWorkflowContext} from '../components/WorkflowContext'
8
+ import {API_VERSION} from '../constants'
9
+
10
+ export function BeginWorkflow(props: DocumentActionProps) {
11
+ const {id, draft} = props
12
+ const {metadata, loading, error, states} = useWorkflowContext(id)
13
+ const client = useClient({apiVersion: API_VERSION})
14
+ const toast = useToast()
15
+ const [beginning, setBeginning] = useState(false)
16
+ const [complete, setComplete] = useState(false)
17
+
18
+ if (error) {
19
+ console.error(error)
20
+ }
21
+
22
+ const handle = useCallback(async () => {
23
+ setBeginning(true)
24
+ const lowestOrderFirstState = await client.fetch(
25
+ `*[_type == "workflow.metadata" && state == $state]|order(orderRank)[0].orderRank`,
26
+ {state: states[0].id}
27
+ )
28
+ client
29
+ .createIfNotExists({
30
+ _id: `workflow-metadata.${id}`,
31
+ _type: `workflow.metadata`,
32
+ documentId: id,
33
+ state: states[0].id,
34
+ orderRank: lowestOrderFirstState
35
+ ? LexoRank.parse(lowestOrderFirstState).genNext().toString()
36
+ : LexoRank.min().toString(),
37
+ })
38
+ .then(() => {
39
+ toast.push({
40
+ status: 'success',
41
+ title: 'Workflow started',
42
+ description: `Document is now "${states[0].title}"`,
43
+ })
44
+ setBeginning(false)
45
+ // Optimistically remove action
46
+ setComplete(true)
47
+ })
48
+ }, [id, states, client, toast])
49
+
50
+ if (!draft || complete || metadata) {
51
+ return null
52
+ }
53
+
54
+ return {
55
+ icon: SplitVerticalIcon,
56
+ type: 'dialog',
57
+ disabled: metadata || loading || error || beginning || complete,
58
+ label: beginning ? `Beginning...` : `Begin Workflow`,
59
+ onHandle: () => {
60
+ handle()
61
+ },
62
+ }
63
+ }
@@ -0,0 +1,41 @@
1
+ import {CheckmarkIcon} from '@sanity/icons'
2
+ import {useCallback} from 'react'
3
+ import {DocumentActionProps, useClient} from 'sanity'
4
+
5
+ import {useWorkflowContext} from '../components/WorkflowContext'
6
+ import {API_VERSION} from '../constants'
7
+
8
+ export function CompleteWorkflow(props: DocumentActionProps) {
9
+ const {id} = props
10
+ const {metadata, loading, error, states} = useWorkflowContext(id)
11
+ const client = useClient({apiVersion: API_VERSION})
12
+
13
+ if (error) {
14
+ console.error(error)
15
+ }
16
+
17
+ const handle = useCallback(() => {
18
+ client.delete(`workflow-metadata.${id}`)
19
+ }, [id, client])
20
+
21
+ if (!metadata) {
22
+ return null
23
+ }
24
+
25
+ const state = states.find((s) => s.id === metadata.state)
26
+ const isLastState = state?.id === states[states.length - 1].id
27
+
28
+ return {
29
+ icon: CheckmarkIcon,
30
+ type: 'dialog',
31
+ disabled: loading || error || !isLastState,
32
+ label: `Complete Workflow`,
33
+ title: isLastState
34
+ ? `Removes the document from the Workflow process`
35
+ : `Cannot remove from workflow until in the last state`,
36
+ onHandle: () => {
37
+ handle()
38
+ },
39
+ color: 'positive',
40
+ }
41
+ }
@@ -0,0 +1,126 @@
1
+ import {ArrowLeftIcon, ArrowRightIcon} from '@sanity/icons'
2
+ import {useToast} from '@sanity/ui'
3
+ import {useCurrentUser, useValidationStatus} from 'sanity'
4
+ import {DocumentActionProps, useClient} from 'sanity'
5
+
6
+ import {useWorkflowContext} from '../components/WorkflowContext'
7
+ import {API_VERSION} from '../constants'
8
+ import {arraysContainMatchingString} from '../helpers/arraysContainMatchingString'
9
+ import {State} from '../types'
10
+
11
+ // eslint-disable-next-line complexity
12
+ export function UpdateWorkflow(props: DocumentActionProps, actionState: State) {
13
+ const {id, type} = props
14
+
15
+ const user = useCurrentUser()
16
+ const client = useClient({apiVersion: API_VERSION})
17
+ const toast = useToast()
18
+ const currentUser = useCurrentUser()
19
+
20
+ const {metadata, loading, error, states} = useWorkflowContext(id)
21
+ const currentState = states.find((s) => s.id === metadata?.state)
22
+ const {assignees = []} = metadata ?? {}
23
+
24
+ // TODO: Shouldn't the document action props contain this?
25
+ const {validation, isValidating} = useValidationStatus(id, type)
26
+ const hasValidationErrors =
27
+ currentState?.requireValidation &&
28
+ !isValidating &&
29
+ validation?.length > 0 &&
30
+ validation.find((v) => v.level === 'error')
31
+
32
+ if (error) {
33
+ console.error(error)
34
+ }
35
+
36
+ const onHandle = (documentId: string, newState: State) => {
37
+ client
38
+ .patch(`workflow-metadata.${documentId}`)
39
+ .set({state: newState.id})
40
+ .commit()
41
+ .then(() => {
42
+ props.onComplete()
43
+ toast.push({
44
+ status: 'success',
45
+ title: `Document state now "${newState.title}"`,
46
+ })
47
+ })
48
+ .catch((err) => {
49
+ props.onComplete()
50
+ console.error(err)
51
+ toast.push({
52
+ status: 'error',
53
+ title: `Document state update failed`,
54
+ })
55
+ })
56
+ }
57
+
58
+ // Remove button if:
59
+ // Document is not in Workflow OR
60
+ // The current State is the same as this actions State
61
+ if (!metadata || (currentState && currentState.id === actionState.id)) {
62
+ return null
63
+ }
64
+
65
+ const currentStateIndex = states.findIndex((s) => s.id === currentState?.id)
66
+ const actionStateIndex = states.findIndex((s) => s.id === actionState.id)
67
+ const direction = actionStateIndex > currentStateIndex ? 'promote' : 'demote'
68
+ const DirectionIcon = direction === 'promote' ? ArrowRightIcon : ArrowLeftIcon
69
+ const directionLabel = direction === 'promote' ? 'Promote' : 'Demote'
70
+
71
+ const userRoleCanUpdateState =
72
+ user?.roles?.length && actionState?.roles?.length
73
+ ? // If the Action state is limited to specific roles
74
+ // check that the current user has one of those roles
75
+ arraysContainMatchingString(
76
+ user.roles.map((r) => r.name),
77
+ actionState.roles
78
+ )
79
+ : // No roles specified on the next state, so anyone can update
80
+ actionState?.roles?.length !== 0
81
+
82
+ const actionStateIsAValidTransition =
83
+ currentState?.id && currentState.transitions.length
84
+ ? // If the Current State limits transitions to specific States
85
+ // Check that the Action State is in Current State's transitions array
86
+ currentState.transitions.includes(actionState.id)
87
+ : // Otherwise this isn't a problem
88
+ true
89
+
90
+ const userAssignmentCanUpdateState = actionState.requireAssignment
91
+ ? // If the Action State requires assigned users
92
+ // Check the current user ID is in the assignees array
93
+ currentUser && assignees.length && assignees.includes(currentUser.id)
94
+ : // Otherwise this isn't a problem
95
+ true
96
+
97
+ let title = `${directionLabel} State to "${actionState.title}"`
98
+
99
+ if (!userRoleCanUpdateState) {
100
+ title = `Your User role cannot ${directionLabel} State to "${actionState.title}"`
101
+ } else if (!actionStateIsAValidTransition) {
102
+ title = `You cannot ${directionLabel} State to "${actionState.title}" from "${currentState?.title}"`
103
+ } else if (!userAssignmentCanUpdateState) {
104
+ title = `You must be assigned to the document to ${directionLabel} State to "${actionState.title}"`
105
+ } else if (currentState?.requireValidation && isValidating) {
106
+ title = `Document is validating, cannot ${directionLabel} State to "${actionState.title}"`
107
+ } else if (hasValidationErrors) {
108
+ title = `Document has validation errors, cannot ${directionLabel} State to "${actionState.title}"`
109
+ }
110
+
111
+ return {
112
+ icon: DirectionIcon,
113
+ disabled:
114
+ loading ||
115
+ error ||
116
+ (currentState?.requireValidation && isValidating) ||
117
+ hasValidationErrors ||
118
+ !currentState ||
119
+ !userRoleCanUpdateState ||
120
+ !actionStateIsAValidTransition ||
121
+ !userAssignmentCanUpdateState,
122
+ title,
123
+ label: actionState.title,
124
+ onHandle: () => onHandle(id, actionState),
125
+ }
126
+ }
@@ -0,0 +1,53 @@
1
+ import {CurrentUser, DocumentBadgeDescription} from 'sanity'
2
+ import {useProjectUsers} from 'sanity-plugin-utils'
3
+
4
+ import {useWorkflowContext} from '../components/WorkflowContext'
5
+ import {API_VERSION} from '../constants'
6
+
7
+ export function AssigneesBadge(
8
+ documentId: string,
9
+ currentUser: CurrentUser | null
10
+ ): DocumentBadgeDescription | null {
11
+ const {metadata, loading, error} = useWorkflowContext(documentId)
12
+ const userList = useProjectUsers({apiVersion: API_VERSION})
13
+
14
+ if (loading || error || !metadata) {
15
+ if (error) {
16
+ console.error(error)
17
+ }
18
+
19
+ return null
20
+ }
21
+
22
+ if (!metadata?.assignees?.length) {
23
+ return {
24
+ label: 'Unassigned',
25
+ }
26
+ }
27
+
28
+ const {assignees} = metadata ?? []
29
+ const hasMe = currentUser
30
+ ? assignees.some((assignee) => assignee === currentUser.id)
31
+ : false
32
+ const assigneesCount = hasMe ? assignees.length - 1 : assignees.length
33
+ const assigneeUsers = userList.filter((user) => assignees.includes(user.id))
34
+ const title = assigneeUsers.map((user) => user.displayName).join(', ')
35
+
36
+ let label
37
+
38
+ if (hasMe && assigneesCount === 0) {
39
+ label = 'Assigned to Me'
40
+ } else if (hasMe && assigneesCount > 0) {
41
+ label = `Me and ${assigneesCount} ${
42
+ assigneesCount === 1 ? 'other' : 'others'
43
+ }`
44
+ } else {
45
+ label = `${assigneesCount} assigned`
46
+ }
47
+
48
+ return {
49
+ label,
50
+ title,
51
+ color: 'primary',
52
+ }
53
+ }
@@ -0,0 +1,28 @@
1
+ import {DocumentBadgeDescription} from 'sanity'
2
+
3
+ import {useWorkflowContext} from '../components/WorkflowContext'
4
+
5
+ export function StateBadge(
6
+ documentId: string
7
+ ): DocumentBadgeDescription | null {
8
+ const {metadata, loading, error, states} = useWorkflowContext(documentId)
9
+ const state = states.find((s) => s.id === metadata?.state)
10
+
11
+ if (loading || error) {
12
+ if (error) {
13
+ console.error(error)
14
+ }
15
+
16
+ return null
17
+ }
18
+
19
+ if (!state) {
20
+ return null
21
+ }
22
+
23
+ return {
24
+ label: state.title,
25
+ // title: state.title,
26
+ color: state?.color,
27
+ }
28
+ }
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import {Box, Flex, Text} from '@sanity/ui'
3
- import {UserAvatar} from 'sanity'
3
+ import {useCurrentUser, UserAvatar} from 'sanity'
4
4
 
5
5
  import {User} from '../../types'
6
6
 
@@ -10,22 +10,26 @@ type AvatarGroupProps = {
10
10
  }
11
11
 
12
12
  export default function AvatarGroup(props: AvatarGroupProps) {
13
- const {users, max = 3} = props
13
+ const currentUser = useCurrentUser()
14
+ const {users, max = 4} = props
14
15
 
15
16
  const len = users?.length
16
- const visibleUsers = React.useMemo(
17
- () => users.slice(0, max),
18
- [users]
19
- ) as User[]
17
+ const {me, visibleUsers} = React.useMemo(() => {
18
+ return {
19
+ me: currentUser?.id ? users.find((u) => u.id === currentUser.id) : undefined,
20
+ visibleUsers: users.filter((u) => u.id !== currentUser?.id).slice(0, max - 1),
21
+ }
22
+ }, [users, max, currentUser])
20
23
 
21
24
  if (!users?.length) {
22
25
  return null
23
26
  }
24
27
 
25
28
  return (
26
- <Flex align="center">
29
+ <Flex align="center" gap={1}>
30
+ {me ? <UserAvatar user={me} /> : null}
27
31
  {visibleUsers.map((user) => (
28
- <Box key={user.id} style={{marginRight: -5}}>
32
+ <Box key={user.id} style={{marginRight: -8}}>
29
33
  <UserAvatar user={user} />
30
34
  </Box>
31
35
  ))}
@@ -0,0 +1,68 @@
1
+ import {CheckmarkIcon} from '@sanity/icons'
2
+ import {Box, Button, Text, Tooltip, useToast} from '@sanity/ui'
3
+ import React from 'react'
4
+ import {useClient} from 'sanity'
5
+
6
+ import {API_VERSION} from '../../constants'
7
+
8
+ type CompleteButtonProps = {
9
+ documentId: string
10
+ disabled: boolean
11
+ }
12
+
13
+ export default function CompleteButton(props: CompleteButtonProps) {
14
+ const {documentId, disabled = false} = props
15
+ const client = useClient({apiVersion: API_VERSION})
16
+ const toast = useToast()
17
+
18
+ const handleComplete: React.MouseEventHandler<HTMLButtonElement> =
19
+ React.useCallback(
20
+ (event) => {
21
+ const id = event.currentTarget.value
22
+
23
+ if (!id) {
24
+ return
25
+ }
26
+
27
+ client
28
+ .delete(`workflow-metadata.${id}`)
29
+ .then(() => {
30
+ toast.push({
31
+ status: 'success',
32
+ title: 'Workflow completed',
33
+ })
34
+ })
35
+ .catch(() => {
36
+ toast.push({
37
+ status: 'error',
38
+ title: 'Could not complete Workflow',
39
+ })
40
+ })
41
+ },
42
+ [client, toast]
43
+ )
44
+
45
+ return (
46
+ <Tooltip
47
+ portal
48
+ content={
49
+ <Box padding={2}>
50
+ <Text size={1}>Remove this document from Workflow</Text>
51
+ </Box>
52
+ }
53
+ >
54
+ <Button
55
+ value={documentId}
56
+ onClick={handleComplete}
57
+ text="Complete"
58
+ icon={CheckmarkIcon}
59
+ tone="positive"
60
+ mode="ghost"
61
+ fontSize={1}
62
+ padding={2}
63
+ tabIndex={-1}
64
+ disabled={disabled}
65
+ />
66
+ </Tooltip>
67
+ )
68
+ }
@@ -1,4 +1,3 @@
1
- import React from 'react'
2
1
  import {Button} from '@sanity/ui'
3
2
  import {EditIcon} from '@sanity/icons'
4
3
  import {useRouter} from 'sanity/router'
@@ -6,10 +5,11 @@ import {useRouter} from 'sanity/router'
6
5
  type EditButtonProps = {
7
6
  id: string
8
7
  type: string
8
+ disabled?: boolean
9
9
  }
10
10
 
11
11
  export default function EditButton(props: EditButtonProps) {
12
- const {id, type} = props
12
+ const {id, type, disabled = false} = props
13
13
  const {navigateIntent} = useRouter()
14
14
 
15
15
  return (
@@ -22,6 +22,7 @@ export default function EditButton(props: EditButtonProps) {
22
22
  tabIndex={-1}
23
23
  icon={EditIcon}
24
24
  text="Edit"
25
+ disabled={disabled}
25
26
  />
26
27
  )
27
28
  }
@@ -0,0 +1,38 @@
1
+ import {Flex, Card, Spinner} from '@sanity/ui'
2
+ import {Preview, SanityDocument, StringInputProps, useSchema} from 'sanity'
3
+ import {useListeningQuery, Feedback} from 'sanity-plugin-utils'
4
+
5
+ import EditButton from './EditButton'
6
+
7
+ // TODO: Update this to use the same component as the Tool
8
+ export default function Field(props: StringInputProps) {
9
+ const schema = useSchema()
10
+ const {data, loading, error} = useListeningQuery<SanityDocument>(
11
+ `*[_id in [$id, $draftId]]|order(_updatedAt)[0]`,
12
+ {
13
+ params: {
14
+ id: String(props.value),
15
+ draftId: `drafts.${String(props.value)}`,
16
+ },
17
+ }
18
+ )
19
+
20
+ if (loading) {
21
+ return <Spinner />
22
+ }
23
+
24
+ const schemaType = schema.get(data?._type ?? ``)
25
+
26
+ if (error || !data?._type || !schemaType) {
27
+ return <Feedback tone="critical" title="Error with query" />
28
+ }
29
+
30
+ return (
31
+ <Card border padding={2}>
32
+ <Flex align="center" justify="space-between" gap={2}>
33
+ <Preview layout="default" value={data} schemaType={schemaType} />
34
+ <EditButton id={data._id} type={data._type} />
35
+ </Flex>
36
+ </Card>
37
+ )
38
+ }
@@ -0,0 +1,21 @@
1
+ import {useEffect} from 'react'
2
+ import {useValidationStatus, ValidationStatus} from 'sanity'
3
+
4
+ type ValidateProps = {
5
+ documentId: string
6
+ type: string
7
+ onChange: (validation: ValidationStatus) => void
8
+ }
9
+
10
+ // Document validation is siloed into its own component
11
+ // Because it's not performant to run on a lot of documents
12
+ export default function Validate(props: ValidateProps) {
13
+ const {documentId, type, onChange} = props
14
+ const {isValidating, validation = []} = useValidationStatus(documentId, type)
15
+
16
+ useEffect(() => {
17
+ onChange({isValidating, validation})
18
+ }, [onChange, isValidating, validation])
19
+
20
+ return null
21
+ }
@@ -0,0 +1,37 @@
1
+ import {ErrorOutlineIcon, WarningOutlineIcon} from '@sanity/icons'
2
+ import {ValidationMarker} from '@sanity/types'
3
+ import {Box, Text, Tooltip} from '@sanity/ui'
4
+ import {TextWithTone} from 'sanity'
5
+
6
+ type ValidationStatusProps = {
7
+ validation: ValidationMarker[]
8
+ }
9
+
10
+ export function ValidationStatus(props: ValidationStatusProps) {
11
+ const {validation = []} = props
12
+
13
+ if (!validation.length) {
14
+ return null
15
+ }
16
+
17
+ const hasError = validation.some((item) => item.level === 'error')
18
+
19
+ return (
20
+ <Tooltip
21
+ portal
22
+ content={
23
+ <Box padding={2}>
24
+ <Text size={1}>
25
+ {validation.length === 1
26
+ ? `1 validation issue`
27
+ : `${validation.length} validation issues`}
28
+ </Text>
29
+ </Box>
30
+ }
31
+ >
32
+ <TextWithTone tone={hasError ? `critical` : `caution`} size={1}>
33
+ {hasError ? <ErrorOutlineIcon /> : <WarningOutlineIcon />}
34
+ </TextWithTone>
35
+ </Tooltip>
36
+ )
37
+ }
@@ -0,0 +1,32 @@
1
+ import {EditIcon} from '@sanity/icons'
2
+ import {PreviewValue, SanityDocument} from '@sanity/types'
3
+ import {Box, Text, Tooltip} from '@sanity/ui'
4
+ import {TextWithTone} from 'sanity'
5
+
6
+ import {TimeAgo} from './TimeAgo'
7
+
8
+ export function DraftStatus(props: {document?: PreviewValue | Partial<SanityDocument> | null}) {
9
+ const {document} = props
10
+ const updatedAt = document && '_updatedAt' in document && document._updatedAt
11
+
12
+ return (
13
+ <Tooltip
14
+ portal
15
+ content={
16
+ <Box padding={2}>
17
+ <Text size={1}>
18
+ {document ? (
19
+ <>Edited {updatedAt && <TimeAgo time={updatedAt} />}</>
20
+ ) : (
21
+ <>No unpublished edits</>
22
+ )}
23
+ </Text>
24
+ </Box>
25
+ }
26
+ >
27
+ <TextWithTone tone="caution" dimmed={!document} muted={!document} size={1}>
28
+ <EditIcon />
29
+ </TextWithTone>
30
+ </Tooltip>
31
+ )
32
+ }
@@ -0,0 +1,39 @@
1
+ import {PublishIcon} from '@sanity/icons'
2
+ import {PreviewValue, SanityDocument} from '@sanity/types'
3
+ import {Box, Text, Tooltip} from '@sanity/ui'
4
+ import {TextWithTone} from 'sanity'
5
+
6
+ import {TimeAgo} from './TimeAgo'
7
+
8
+ export function PublishedStatus(props: {
9
+ document?: PreviewValue | Partial<SanityDocument> | null
10
+ }) {
11
+ const {document} = props
12
+ const updatedAt = document && '_updatedAt' in document && document._updatedAt
13
+
14
+ return (
15
+ <Tooltip
16
+ portal
17
+ content={
18
+ <Box padding={2}>
19
+ <Text size={1}>
20
+ {document ? (
21
+ <>Published {updatedAt && <TimeAgo time={updatedAt} />}</>
22
+ ) : (
23
+ <>Not published</>
24
+ )}
25
+ </Text>
26
+ </Box>
27
+ }
28
+ >
29
+ <TextWithTone
30
+ tone="positive"
31
+ dimmed={!document}
32
+ muted={!document}
33
+ size={1}
34
+ >
35
+ <PublishIcon />
36
+ </TextWithTone>
37
+ </Tooltip>
38
+ )
39
+ }
@@ -0,0 +1,11 @@
1
+ import {useTimeAgo} from 'sanity'
2
+
3
+ export interface TimeAgoProps {
4
+ time: string | Date
5
+ }
6
+
7
+ export function TimeAgo({time}: TimeAgoProps) {
8
+ const timeAgo = useTimeAgo(time)
9
+
10
+ return <span title={timeAgo}>{timeAgo} ago</span>
11
+ }