Skip to content

Commit 0a76390

Browse files
pedrobonaminsjelfull
andauthoredMar 12, 2024
feat(tasks): track activity changes with document history. (#5965)
* feat(tasks): track task changes with document history Signed-off-by: Fred Carlsen <fred@sjelfull.no> * feat(tasks): update tasks activity log to display values * fix(tasks): always fetch publishedId, add client api version * fix(tasks): update status selector and workspace provider to disable warnings generated --------- Signed-off-by: Fred Carlsen <fred@sjelfull.no> Co-authored-by: Fred Carlsen <fred@sjelfull.no>
1 parent 908577e commit 0a76390

File tree

12 files changed

+507
-145
lines changed

12 files changed

+507
-145
lines changed
 

‎packages/sanity/src/tasks/src/tasks/components/activity/TaskActivityEditedAt.tsx

+5-32
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,19 @@
1-
import {DotIcon} from '@sanity/icons'
21
import {Box, Flex, Text} from '@sanity/ui'
32
import {memo} from 'react'
43

54
import {Tooltip} from '../../../../../ui-components'
6-
import {getStringForKey, UpdatedTimeAgo} from './helpers'
5+
import {getChangeDetails, UpdatedTimeAgo, UserName} from './helpers'
6+
import {type FieldChange} from './helpers/parseTransactions'
77

88
interface EditedAtProps {
9-
activity: {
10-
author: string
11-
field: string
12-
from?: string | null
13-
to?: string | null
14-
timestamp: string
15-
}
9+
activity: FieldChange
1610
}
1711

1812
export const EditedAt = memo(
1913
function EditedAt(props: EditedAtProps) {
2014
const {activity} = props
21-
let key: string = activity.field
22-
let showToValue: boolean = key === 'dueDate' || key === 'status' || key === 'targetContent'
23-
24-
//If the status is changed to be done
25-
if (activity.field === 'status' && activity.to === 'done') {
26-
key = 'statusDone'
27-
showToValue = true
28-
}
29-
//If a task is unassigned - it goes from having a assignee to be unassigned
30-
if (activity.field === 'assignedTo' && !!activity.to && activity.from) {
31-
key = 'unassigned'
32-
}
33-
34-
//Set the due date for the first time
35-
if (activity.field === 'dueDate' && (activity.from === null || undefined) && activity.to) {
36-
key = 'dueDateSet'
37-
showToValue = true
38-
}
39-
4015
const {formattedDate, timeAgo} = UpdatedTimeAgo(activity.timestamp)
41-
const {icon, string} = getStringForKey(key) || {icon: null, string: ''}
16+
const {icon, text, changeTo} = getChangeDetails(activity)
4217

4318
return (
4419
<Flex gap={1}>
@@ -48,9 +23,7 @@ export const EditedAt = memo(
4823
</Box>
4924
</Box>
5025
<Text muted size={1}>
51-
<strong style={{fontWeight: 600}}>{activity.author} </strong>
52-
{string} {showToValue && <strong style={{fontWeight: 600}}>{activity.to}</strong>}{' '}
53-
<DotIcon />{' '}
26+
<UserName userId={activity.author} /> {text} {changeTo}{' '}
5427
<Tooltip content={formattedDate} placement="top-end">
5528
<time dateTime={formattedDate}>{timeAgo}</time>
5629
</Tooltip>

‎packages/sanity/src/tasks/src/tasks/components/activity/TasksActivityCreatedAt.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {DotIcon} from '@sanity/icons'
21
import {Flex, Text, TextSkeleton} from '@sanity/ui'
32
import {memo} from 'react'
43
import {useUser} from 'sanity'
@@ -31,7 +30,7 @@ export const TasksActivityCreatedAt = memo(
3130
<strong style={{fontWeight: 600}}>
3231
{loading ? <UserSkeleton /> : user?.displayName ?? 'Unknown user'}{' '}
3332
</strong>
34-
created this task <DotIcon />{' '}
33+
created this task {' '}
3534
<Tooltip content={formattedDate} placement="top-end">
3635
<time dateTime={createdAt}>{timeAgo}</time>
3736
</Tooltip>

‎packages/sanity/src/tasks/src/tasks/components/activity/TasksActivityLog.tsx

+117-55
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import {Box, Flex, Stack, Text} from '@sanity/ui'
22
import {uuid} from '@sanity/uuid'
33
import {AnimatePresence, motion, type Variants} from 'framer-motion'
4-
import {Fragment, useCallback, useMemo} from 'react'
5-
import {type FormPatch, LoadingBlock, type PatchEvent, type Path, useCurrentUser} from 'sanity'
4+
import {useCallback, useEffect, useMemo, useState} from 'react'
5+
import {
6+
type FormPatch,
7+
getPublishedId,
8+
LoadingBlock,
9+
type PatchEvent,
10+
type Path,
11+
type TransactionLogEventWithEffects,
12+
useClient,
13+
useCurrentUser,
14+
} from 'sanity'
615
import styled from 'styled-components'
716

17+
import {getJsonStream} from '../../../../../core/store/_legacy/history/history/getJsonStream'
818
import {
919
type CommentBaseCreatePayload,
1020
type CommentCreatePayload,
@@ -16,13 +26,77 @@ import {
1626
type CommentUpdatePayload,
1727
useComments,
1828
} from '../../../../../structure/comments'
29+
import {API_VERSION} from '../../constants/API_VERSION'
1930
import {type TaskDocument} from '../../types'
31+
import {CurrentWorkspaceProvider} from '../form/CurrentWorkspaceProvider'
32+
import {type FieldChange, trackFieldChanges} from './helpers/parseTransactions'
2033
import {EditedAt} from './TaskActivityEditedAt'
2134
import {TasksActivityCommentInput} from './TasksActivityCommentInput'
2235
import {TasksActivityCreatedAt} from './TasksActivityCreatedAt'
2336
import {ActivityItem} from './TasksActivityItem'
2437
import {TasksSubscribers} from './TasksSubscribers'
2538

39+
function useActivityLog(task: TaskDocument) {
40+
const [changes, setChanges] = useState<FieldChange[]>([])
41+
const client = useClient({apiVersion: API_VERSION})
42+
const {dataset, token} = client.config()
43+
44+
const queryParams = `tag=sanity.studio.tasks.history&effectFormat=mendoza&excludeContent=true&includeIdentifiedDocumentsOnly=true&reverse=true`
45+
const transactionsUrl = client.getUrl(
46+
`/data/history/${dataset}/transactions/${getPublishedId(task._id)}?${queryParams}`,
47+
)
48+
49+
const fetchAndParse = useCallback(
50+
async (newestTaskDocument: TaskDocument) => {
51+
try {
52+
const transactions: TransactionLogEventWithEffects[] = []
53+
54+
const stream = await getJsonStream(transactionsUrl, token)
55+
const reader = stream.getReader()
56+
let result
57+
for (;;) {
58+
result = await reader.read()
59+
if (result.done) {
60+
break
61+
}
62+
if ('error' in result.value) {
63+
throw new Error(result.value.error.description || result.value.error.type)
64+
}
65+
transactions.push(result.value)
66+
}
67+
68+
const fieldsToTrack: (keyof Omit<TaskDocument, '_rev'>)[] = [
69+
'createdByUser',
70+
'title',
71+
'description',
72+
'dueBy',
73+
'assignedTo',
74+
'status',
75+
'target',
76+
]
77+
78+
const parsedChanges = await trackFieldChanges(
79+
newestTaskDocument,
80+
[...transactions],
81+
fieldsToTrack,
82+
)
83+
84+
setChanges(parsedChanges)
85+
} catch (error) {
86+
console.error('Failed to fetch and parse activity log', error)
87+
}
88+
},
89+
[transactionsUrl, token],
90+
)
91+
92+
useEffect(() => {
93+
fetchAndParse(task)
94+
// Task is updated on every change, wait until the revision changes to update the activity log.
95+
// eslint-disable-next-line react-hooks/exhaustive-deps
96+
}, [fetchAndParse, task._rev])
97+
return {changes}
98+
}
99+
26100
const EMPTY_ARRAY: [] = []
27101

28102
const VARIANTS: Variants = {
@@ -45,14 +119,6 @@ interface TasksActivityLogProps {
45119
value: TaskDocument
46120
}
47121

48-
interface ActivityLogItem {
49-
author: string
50-
field: string
51-
from: string
52-
timestamp: string
53-
to?: string
54-
}
55-
56122
type Activity =
57123
| {
58124
_type: 'comment'
@@ -61,7 +127,7 @@ type Activity =
61127
}
62128
| {
63129
_type: 'activity'
64-
payload: ActivityLogItem
130+
payload: FieldChange
65131
timestamp: string
66132
}
67133

@@ -148,13 +214,12 @@ export function TasksActivityLog(props: TasksActivityLogProps) {
148214
[operation],
149215
)
150216

151-
// TODO: Get the task real activity.
152-
const activityData: ActivityLogItem[] = EMPTY_ARRAY
217+
const activityData = useActivityLog(value).changes
153218

154219
const activity: Activity[] = useMemo(() => {
155220
const taskActivity: Activity[] = activityData.map((item) => ({
156221
_type: 'activity' as const,
157-
payload: item as ActivityLogItem,
222+
payload: item,
158223
timestamp: item.timestamp,
159224
}))
160225
const commentsActivity: Activity[] = taskComments.map((comment) => ({
@@ -199,48 +264,45 @@ export function TasksActivityLog(props: TasksActivityLogProps) {
199264
)}
200265

201266
{currentUser && (
202-
<Stack space={4} marginTop={1}>
203-
{taskComments.length > 0 && (
204-
<Fragment>
205-
{activity.map((item) => {
206-
if (item._type === 'activity') {
207-
return <EditedAt key={item.timestamp} activity={item.payload} />
208-
}
209-
210-
return (
211-
<ActivityItem
267+
<CurrentWorkspaceProvider>
268+
<Stack space={4} marginTop={1}>
269+
{activity.map((item) => {
270+
if (item._type === 'activity') {
271+
return <EditedAt key={item.timestamp} activity={item.payload} />
272+
}
273+
return (
274+
<ActivityItem
275+
key={item.payload.parentComment._id}
276+
userId={item.payload.parentComment.authorId}
277+
>
278+
<CommentsListItem
279+
avatarConfig={COMMENTS_LIST_ITEM_AVATAR_CONFIG}
280+
canReply
281+
currentUser={currentUser}
282+
innerPadding={1}
283+
isSelected={false}
212284
key={item.payload.parentComment._id}
213-
userId={item.payload.parentComment.authorId}
214-
>
215-
<CommentsListItem
216-
avatarConfig={COMMENTS_LIST_ITEM_AVATAR_CONFIG}
217-
canReply
218-
currentUser={currentUser}
219-
innerPadding={1}
220-
isSelected={false}
221-
key={item.payload.parentComment._id}
222-
mentionOptions={mentionOptions}
223-
mode="default" // TODO: set dynamic mode?
224-
onCreateRetry={handleCommentCreateRetry}
225-
onDelete={handleCommentRemove}
226-
onEdit={handleCommentEdit}
227-
onReactionSelect={handleCommentReact}
228-
onReply={handleCommentReply}
229-
parentComment={item.payload.parentComment}
230-
replies={item.payload.replies}
231-
/>
232-
</ActivityItem>
233-
)
234-
})}
235-
</Fragment>
236-
)}
237-
238-
<TasksActivityCommentInput
239-
currentUser={currentUser}
240-
mentionOptions={mentionOptions}
241-
onSubmit={handleCommentCreate}
242-
/>
243-
</Stack>
285+
mentionOptions={mentionOptions}
286+
mode="default" // TODO: set dynamic mode?
287+
onCreateRetry={handleCommentCreateRetry}
288+
onDelete={handleCommentRemove}
289+
onEdit={handleCommentEdit}
290+
onReactionSelect={handleCommentReact}
291+
onReply={handleCommentReply}
292+
parentComment={item.payload.parentComment}
293+
replies={item.payload.replies}
294+
/>
295+
</ActivityItem>
296+
)
297+
})}
298+
299+
<TasksActivityCommentInput
300+
currentUser={currentUser}
301+
mentionOptions={mentionOptions}
302+
onSubmit={handleCommentCreate}
303+
/>
304+
</Stack>
305+
</CurrentWorkspaceProvider>
244306
)}
245307
</MotionStack>
246308
)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {describe, expect, it} from '@jest/globals'
2+
3+
import {groupChanges} from './groupChanges'
4+
import {type FieldChange} from './parseTransactions'
5+
6+
describe('Tests grouping the changes', () => {
7+
it('Should group the changes done by the same user in the title field', () => {
8+
const changes: FieldChange[] = [
9+
{
10+
field: 'title',
11+
to: 'Prepare the tasks PR no',
12+
from: 'Prepare the tasks PR',
13+
timestamp: '2024-03-11T08:31:12.619628Z',
14+
author: 'piIz2Gfg5',
15+
},
16+
{
17+
field: 'title',
18+
from: 'Prepare the tasks PR no',
19+
to: 'Prepare the tasks PR now',
20+
timestamp: '2024-03-11T08:31:13.627854Z',
21+
author: 'piIz2Gfg5',
22+
},
23+
]
24+
const output = groupChanges(changes)
25+
expect(output).toEqual([
26+
{
27+
field: 'title',
28+
from: 'Prepare the tasks PR',
29+
to: 'Prepare the tasks PR now',
30+
timestamp: '2024-03-11T08:31:13.627854Z',
31+
author: 'piIz2Gfg5',
32+
},
33+
])
34+
})
35+
it('Should group the changes done by the same user in the title field, with a max of 5 minutes.', () => {
36+
const changes: FieldChange[] = [
37+
{
38+
field: 'title',
39+
to: 'Prepare the tasks PR no',
40+
from: 'Prepare the tasks PR',
41+
timestamp: '2024-03-11T08:31:12.619628Z',
42+
author: 'piIz2Gfg5',
43+
},
44+
{
45+
field: 'title',
46+
from: 'Prepare the tasks PR no',
47+
to: 'Prepare the tasks PR now',
48+
timestamp: '2024-03-11T08:31:13.627854Z',
49+
author: 'piIz2Gfg5',
50+
},
51+
{
52+
field: 'title',
53+
from: 'Prepare the tasks PR now',
54+
to: 'Prepare the tasks PR',
55+
timestamp: '2024-03-11T08:38:13.627854Z',
56+
author: 'piIz2Gfg5',
57+
},
58+
]
59+
const output = groupChanges(changes)
60+
expect(output).toEqual([
61+
{
62+
field: 'title',
63+
from: 'Prepare the tasks PR',
64+
to: 'Prepare the tasks PR now',
65+
timestamp: '2024-03-11T08:31:13.627854Z',
66+
author: 'piIz2Gfg5',
67+
},
68+
{
69+
field: 'title',
70+
from: 'Prepare the tasks PR now',
71+
to: 'Prepare the tasks PR',
72+
timestamp: '2024-03-11T08:38:13.627854Z',
73+
author: 'piIz2Gfg5',
74+
},
75+
])
76+
})
77+
it('Should not group the changes if the user did changes to other fields in between', () => {
78+
const changes: FieldChange[] = [
79+
{
80+
field: 'title',
81+
to: 'Prepare the tasks PR no',
82+
from: 'Prepare the tasks PR',
83+
timestamp: '2024-03-11T08:31:12.619628Z',
84+
author: 'piIz2Gfg5',
85+
},
86+
{
87+
field: 'assignedTo',
88+
from: 'old',
89+
to: 'new',
90+
timestamp: '2024-03-11T08:31:13.627854Z',
91+
author: 'piIz2Gfg5',
92+
},
93+
{
94+
field: 'title',
95+
from: 'Prepare the tasks PR no',
96+
to: 'Prepare the tasks PR now',
97+
timestamp: '2024-03-11T08:31:13.627854Z',
98+
author: 'piIz2Gfg5',
99+
},
100+
]
101+
const output = groupChanges(changes)
102+
103+
expect(output).toEqual(changes)
104+
})
105+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {type TaskDocument} from '../../../types'
2+
import {type FieldChange} from './parseTransactions'
3+
4+
const FIELDS_TO_GROUP: (keyof TaskDocument)[] = ['title', 'description', 'target']
5+
const GROUP_TIME = 2 * 60 * 1000 // 2 minutes
6+
export function groupChanges(changes: FieldChange[]): FieldChange[] {
7+
// If we have two or more changes done by the same user in a similar timestamp +- X time, in any of the fields specified we group them together.
8+
const groupedChanges: FieldChange[] = []
9+
for (const change of changes) {
10+
const lastChangeProcessed = groupedChanges[groupedChanges.length - 1]
11+
if (!lastChangeProcessed) {
12+
groupedChanges.push(change)
13+
continue
14+
}
15+
if (!FIELDS_TO_GROUP.includes(change.field)) {
16+
groupedChanges.push(change)
17+
continue
18+
}
19+
if (
20+
lastChangeProcessed.author === change.author &&
21+
lastChangeProcessed.field === change.field
22+
) {
23+
// Check the timestamp difference
24+
const lastChangeDate = new Date(lastChangeProcessed.timestamp)
25+
const changeDate = new Date(change.timestamp)
26+
const diff = Math.abs(lastChangeDate.getTime() - changeDate.getTime())
27+
if (diff <= GROUP_TIME) {
28+
// We keep the from value and update the to value, and the date.
29+
lastChangeProcessed.to = change.to
30+
lastChangeProcessed.timestamp = change.timestamp
31+
continue
32+
}
33+
}
34+
groupedChanges.push(change)
35+
}
36+
37+
return groupedChanges
38+
}
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,176 @@
1-
import {
2-
CalendarIcon,
3-
CheckmarkCircleIcon,
4-
CircleIcon,
5-
EditIcon,
6-
LinkIcon,
7-
UserIcon,
8-
} from '@sanity/icons'
1+
import {CalendarIcon, CircleIcon, EditIcon, LinkIcon, UserIcon} from '@sanity/icons'
2+
import {TextSkeleton} from '@sanity/ui'
93
import {type ReactElement} from 'react'
104
import {
115
type RelativeTimeOptions,
126
useDateTimeFormat,
137
type UseDateTimeFormatOptions,
148
useRelativeTime,
9+
useSchema,
10+
useUser,
1511
} from 'sanity'
12+
import {IntentLink} from 'sanity/router'
13+
import styled from 'styled-components'
1614

17-
interface KeyStringMapValue {
18-
string: string
19-
icon: ReactElement
20-
link?: ReactElement
21-
}
15+
import {TASK_STATUS} from '../../../constants/TaskStatus'
16+
import {useDocumentPreviewValues} from '../../../hooks/useDocumentPreviewValues'
17+
import {type TaskTarget} from '../../../types'
18+
import {type FieldChange} from './parseTransactions'
2219

2320
const DATE_FORMAT_OPTIONS: UseDateTimeFormatOptions = {
24-
dateStyle: 'medium',
21+
month: 'long',
22+
day: '2-digit',
23+
minute: '2-digit',
24+
hour: '2-digit',
25+
second: '2-digit',
2526
}
2627

2728
const RELATIVE_TIME_OPTIONS: RelativeTimeOptions = {
2829
minimal: true,
2930
useTemporalPhrase: true,
3031
}
3132

33+
const Strong = styled.strong`
34+
font-weight: 600;
35+
`
36+
3237
export function UpdatedTimeAgo(timestamp: string) {
38+
const date = new Date(timestamp)
3339
const dateFormatter = useDateTimeFormat(DATE_FORMAT_OPTIONS)
34-
const formattedDate = dateFormatter.format(new Date(timestamp))
40+
const formattedDate = dateFormatter.format(date)
3541

36-
const updatedTimeAgo = useRelativeTime(formattedDate || '', RELATIVE_TIME_OPTIONS)
42+
const updatedTimeAgo = useRelativeTime(date || '', RELATIVE_TIME_OPTIONS)
3743

3844
return {timeAgo: updatedTimeAgo, formattedDate}
3945
}
4046

41-
export function getStringForKey(key: string): KeyStringMapValue | undefined {
42-
const keyStringMap: {[key: string]: KeyStringMapValue} = {
43-
assignedTo: {string: 'assigned to', icon: <UserIcon />},
44-
unassigned: {string: 'unassigned this task', icon: <UserIcon />},
45-
dueDate: {string: 'changed the due date to', icon: <CalendarIcon />},
46-
dueDateSet: {
47-
string: 'set the due date to',
48-
icon: <CalendarIcon />,
49-
},
50-
description: {string: 'updated the task description', icon: <EditIcon />},
51-
title: {string: 'updated the task title', icon: <EditIcon />},
52-
targetContent: {string: 'set the target content to', icon: <LinkIcon />},
53-
statusDone: {
54-
string: 'changed status to',
55-
icon: <CheckmarkCircleIcon />,
56-
},
57-
status: {
58-
string: 'changed status to',
59-
icon: <CircleIcon />,
60-
},
47+
export function UserName({userId}: {userId: string}) {
48+
const [user, isLoading] = useUser(userId)
49+
return isLoading ? <TextSkeleton style={{width: '15ch'}} /> : <Strong>{user?.displayName}</Strong>
50+
}
51+
52+
const DUE_BY_DATE_OPTIONS: UseDateTimeFormatOptions = {
53+
month: 'short',
54+
day: 'numeric',
55+
}
56+
57+
function DueByChange({date}: {date: string}) {
58+
const dueBy = new Date(date)
59+
const dateFormatter = useDateTimeFormat(DUE_BY_DATE_OPTIONS)
60+
const formattedDate = dateFormatter.format(dueBy)
61+
return <Strong>{formattedDate}</Strong>
62+
}
63+
64+
const LinkWrapper = styled.span`
65+
> a {
66+
color: var(--card-fg-muted-color);
67+
text-decoration: underline;
68+
text-underline-offset: 1px;
69+
font-weight: 600;
70+
}
71+
`
72+
73+
function TargetContentChange({target}: {target: TaskTarget}) {
74+
const schema = useSchema()
75+
const documentId = target.document._ref
76+
const documentType = target.documentType
77+
const documentSchema = schema.get(documentType)
78+
const {isLoading, value} = useDocumentPreviewValues({
79+
documentId,
80+
documentType,
81+
})
82+
83+
if (isLoading) {
84+
return <TextSkeleton style={{width: '15ch'}} />
85+
}
86+
if (!documentSchema) {
87+
return null
6188
}
6289

63-
return keyStringMap[key]
90+
return (
91+
<LinkWrapper>
92+
<IntentLink intent="edit" params={{id: documentId, type: documentType}}>
93+
{value?.title}
94+
</IntentLink>
95+
</LinkWrapper>
96+
)
97+
}
98+
99+
export function getChangeDetails(activity: FieldChange): {
100+
text: string
101+
icon: ReactElement
102+
changeTo?: ReactElement
103+
} {
104+
switch (activity.field) {
105+
case 'status': {
106+
const statusTitle = TASK_STATUS.find((s) => s.value === activity.to)?.title
107+
return {
108+
text: 'changed status to',
109+
icon: TASK_STATUS.find((s) => s.value === activity.to)?.icon || <CircleIcon />,
110+
changeTo: <Strong>{statusTitle}</Strong>,
111+
}
112+
}
113+
case 'target':
114+
if (!activity.to)
115+
return {
116+
text: 'removed target content',
117+
icon: <LinkIcon />,
118+
changeTo: undefined,
119+
}
120+
return {
121+
text: 'set target content to',
122+
icon: <LinkIcon />,
123+
changeTo: <TargetContentChange target={activity.to} />,
124+
}
125+
case 'dueBy':
126+
if (!activity.from) {
127+
return {
128+
text: 'set the due date to',
129+
icon: <CalendarIcon />,
130+
changeTo: <DueByChange date={activity.to} />,
131+
}
132+
}
133+
if (!activity.to) {
134+
return {
135+
text: 'removed the due date',
136+
icon: <CalendarIcon />,
137+
changeTo: undefined,
138+
}
139+
}
140+
return {
141+
text: 'changed the due date to',
142+
icon: <CalendarIcon />,
143+
changeTo: <DueByChange date={activity.to} />,
144+
}
145+
case 'assignedTo':
146+
if (!activity.to) {
147+
return {
148+
text: 'unassigned this task',
149+
icon: <UserIcon />,
150+
changeTo: undefined,
151+
}
152+
}
153+
return {
154+
text: 'assigned to',
155+
icon: <UserIcon />,
156+
changeTo: <UserName userId={activity.to} />,
157+
}
158+
case 'description':
159+
return {
160+
text: 'updated the task description',
161+
icon: <EditIcon />,
162+
changeTo: undefined,
163+
}
164+
case 'title':
165+
return {
166+
text: 'updated the task title',
167+
icon: <EditIcon />,
168+
changeTo: undefined,
169+
}
170+
default:
171+
return {
172+
text: '',
173+
icon: <CircleIcon />,
174+
}
175+
}
64176
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {applyPatch} from 'mendoza'
2+
import {type TransactionLogEventWithEffects} from 'sanity'
3+
4+
import {type TaskDocument} from '../../../types'
5+
import {groupChanges} from './groupChanges'
6+
7+
export interface FieldChange {
8+
field: keyof TaskDocument
9+
from: any
10+
to: any
11+
timestamp: string
12+
author: string
13+
}
14+
15+
function omitRev(document: TaskDocument) {
16+
const {_rev, ...doc} = document
17+
return doc
18+
}
19+
20+
/**
21+
* Tracks changes to specified fields across document versions by applying patches in reverse.
22+
* @param newestDocument - The latest state of the document.
23+
* @param transactions - An array of transactions containing patches.
24+
* @param fieldsToTrack - The fields to track for changes.
25+
* @returns An array of changes for the tracked fields.
26+
*/
27+
export function trackFieldChanges(
28+
newestDocument: TaskDocument,
29+
transactions: TransactionLogEventWithEffects[],
30+
fieldsToTrack: (keyof Omit<TaskDocument, '_rev'>)[],
31+
): FieldChange[] {
32+
let currentDocument: Omit<TaskDocument, '_rev'> = omitRev(newestDocument)
33+
const changes: FieldChange[] = []
34+
let previousDocument = currentDocument
35+
36+
for (const transaction of transactions) {
37+
const {timestamp, effects} = transaction
38+
39+
// Assuming there's a single document being tracked in this transaction
40+
const documentId = transaction.documentIDs[0]
41+
const effect = effects[documentId]
42+
if (!effect || !effect.revert) continue
43+
44+
previousDocument = applyPatch(currentDocument, effect.revert)
45+
46+
// Track changes for specified fields
47+
// eslint-disable-next-line no-loop-func
48+
fieldsToTrack.forEach((field) => {
49+
if (previousDocument?.[field] !== currentDocument?.[field]) {
50+
changes.push({
51+
field,
52+
from: previousDocument?.[field],
53+
to: currentDocument?.[field],
54+
timestamp,
55+
author: transaction.author,
56+
})
57+
}
58+
})
59+
60+
// Prepare for next iteration
61+
currentDocument = previousDocument
62+
}
63+
64+
const changesSortedByTimestamp = changes.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
65+
// Find the moment the task was created by the user.
66+
const createdByUserIndex = changesSortedByTimestamp.findIndex(
67+
(change) => change.field === 'createdByUser',
68+
)
69+
70+
// Return changes sorted by timestamp in ascending order from the moment the task was created.
71+
return groupChanges(changesSortedByTimestamp.slice(createdByUserIndex + 1))
72+
}

‎packages/sanity/src/tasks/src/tasks/components/form/addonWorkspace/TasksAddOnWorkspaceProvider.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
WorkspaceProvider,
1313
} from 'sanity'
1414

15+
import {API_VERSION} from '../../../constants/API_VERSION'
1516
import {type FormMode} from '../../../types'
1617
import {taskSchema} from './taskSchema'
1718

@@ -24,7 +25,7 @@ function TasksAddonWorkspaceProviderInner({
2425
children: React.ReactNode
2526
mode: FormMode
2627
}) {
27-
const client = useClient()
28+
const client = useClient({apiVersion: API_VERSION})
2829
const apiHost = client.config().apiHost
2930
// TODO: Is basePath necessary here?
3031
const basePath = ''

‎packages/sanity/src/tasks/src/tasks/components/form/addonWorkspace/taskSchema.tsx

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {type ArrayFieldProps, defineField, defineType, type ObjectFieldProps} from 'sanity'
22

3+
import {TASK_STATUS} from '../../../constants/TaskStatus'
34
import {type FormMode} from '../../../types'
45
import {
56
AssigneeCreateFormField,
@@ -139,10 +140,7 @@ export const taskSchema = (mode: FormMode) =>
139140
name: 'status',
140141
title: 'Status',
141142
options: {
142-
list: [
143-
{value: 'open', title: 'To Do'},
144-
{value: 'closed', title: 'Done'},
145-
],
143+
list: TASK_STATUS.map((s) => ({value: s.value, title: s.title})),
146144
},
147145
hidden: true,
148146
},

‎packages/sanity/src/tasks/src/tasks/components/form/fields/StatusSelector.tsx

+9-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {CheckmarkCircleIcon, CheckmarkIcon, CircleIcon} from '@sanity/icons'
1+
import {CheckmarkIcon, CircleIcon} from '@sanity/icons'
22
import {Menu} from '@sanity/ui'
3-
import {type ForwardedRef, forwardRef, type ReactNode} from 'react'
3+
import {type ForwardedRef, forwardRef} from 'react'
44
import {
55
type FormPatch,
66
isString,
@@ -11,25 +11,21 @@ import {
1111
} from 'sanity'
1212

1313
import {Button, MenuButton, MenuItem} from '../../../../../../ui-components'
14-
15-
// TODO: support customizing icons and options.
16-
const OPTION_ICONS: Record<string, ReactNode> = {
17-
closed: <CheckmarkCircleIcon />,
18-
open: <CircleIcon />,
19-
}
14+
import {TASK_STATUS} from '../../../constants/TaskStatus'
2015

2116
export const StatusMenuButton = forwardRef(function StatusMenuButton(
2217
props: {value: string | undefined; options: TitledListValue<string>[]},
2318
ref: ForwardedRef<HTMLButtonElement>,
2419
) {
25-
const {value, options} = props
20+
const {value, options, ...rest} = props
2621
const selectedOption = options.find((option) => option.value === value)
22+
const icon = TASK_STATUS.find((status) => status.value === value)?.icon
2723
return (
2824
<Button
29-
{...props}
25+
{...rest}
3026
ref={ref}
3127
tooltipProps={null}
32-
icon={value && OPTION_ICONS[value]}
28+
icon={icon}
3329
text={selectedOption?.title || value}
3430
tone="default"
3531
mode="ghost"
@@ -54,12 +50,11 @@ export function StatusSelector(props: StatusSelectorProps) {
5450
<Menu>
5551
{options.map((option) => {
5652
const isSelected = value === option.value
53+
const icon = TASK_STATUS.find((status) => status.value === option.value)?.icon
5754
return (
5855
<MenuItem
5956
key={option.title}
60-
icon={
61-
isString(option.value) ? OPTION_ICONS[option.value] || CircleIcon : CircleIcon
62-
}
57+
icon={isString(option.value) ? icon || CircleIcon : CircleIcon}
6358
text={option.title || option.value}
6459
pressed={isSelected}
6560
iconRight={isSelected && <CheckmarkIcon />}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const API_VERSION = '2024-03-05'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {CheckmarkCircleIcon, CircleIcon} from '@sanity/icons'
2+
3+
export const TASK_STATUS = [
4+
{value: 'open', title: 'To Do', icon: <CircleIcon />},
5+
{value: 'closed', title: 'Done', icon: <CheckmarkCircleIcon />},
6+
]

0 commit comments

Comments
 (0)
Please sign in to comment.