Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tasks): track activity changes with document history. #5965

Merged
merged 4 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,44 +1,19 @@
import {DotIcon} from '@sanity/icons'
import {Box, Flex, Text} from '@sanity/ui'
import {memo} from 'react'

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

interface EditedAtProps {
activity: {
author: string
field: string
from?: string | null
to?: string | null
timestamp: string
}
activity: FieldChange
}

export const EditedAt = memo(
function EditedAt(props: EditedAtProps) {
const {activity} = props
let key: string = activity.field
let showToValue: boolean = key === 'dueDate' || key === 'status' || key === 'targetContent'

//If the status is changed to be done
if (activity.field === 'status' && activity.to === 'done') {
key = 'statusDone'
showToValue = true
}
//If a task is unassigned - it goes from having a assignee to be unassigned
if (activity.field === 'assignedTo' && !!activity.to && activity.from) {
key = 'unassigned'
}

//Set the due date for the first time
if (activity.field === 'dueDate' && (activity.from === null || undefined) && activity.to) {
key = 'dueDateSet'
showToValue = true
}

const {formattedDate, timeAgo} = UpdatedTimeAgo(activity.timestamp)
const {icon, string} = getStringForKey(key) || {icon: null, string: ''}
const {icon, text, changeTo} = getChangeDetails(activity)

return (
<Flex gap={1}>
Expand All @@ -48,9 +23,7 @@ export const EditedAt = memo(
</Box>
</Box>
<Text muted size={1}>
<strong style={{fontWeight: 600}}>{activity.author} </strong>
{string} {showToValue && <strong style={{fontWeight: 600}}>{activity.to}</strong>}{' '}
<DotIcon />{' '}
<UserName userId={activity.author} /> {text} {changeTo} •{' '}
<Tooltip content={formattedDate} placement="top-end">
<time dateTime={formattedDate}>{timeAgo}</time>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {DotIcon} from '@sanity/icons'
import {Flex, Text, TextSkeleton} from '@sanity/ui'
import {memo} from 'react'
import {useUser} from 'sanity'
Expand Down Expand Up @@ -31,7 +30,7 @@ export const TasksActivityCreatedAt = memo(
<strong style={{fontWeight: 600}}>
{loading ? <UserSkeleton /> : user?.displayName ?? 'Unknown user'}{' '}
</strong>
created this task <DotIcon />{' '}
created this task {' '}
<Tooltip content={formattedDate} placement="top-end">
<time dateTime={createdAt}>{timeAgo}</time>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import {Box, Flex, Stack, Text} from '@sanity/ui'
import {uuid} from '@sanity/uuid'
import {AnimatePresence, motion, type Variants} from 'framer-motion'
import {Fragment, useCallback, useMemo} from 'react'
import {type FormPatch, LoadingBlock, type PatchEvent, type Path, useCurrentUser} from 'sanity'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {
type FormPatch,
getPublishedId,
LoadingBlock,
type PatchEvent,
type Path,
type TransactionLogEventWithEffects,
useClient,
useCurrentUser,
} from 'sanity'
import styled from 'styled-components'

import {getJsonStream} from '../../../../../core/store/_legacy/history/history/getJsonStream'
import {
type CommentBaseCreatePayload,
type CommentCreatePayload,
Expand All @@ -16,13 +26,77 @@ import {
type CommentUpdatePayload,
useComments,
} from '../../../../../structure/comments'
import {API_VERSION} from '../../constants/API_VERSION'
import {type TaskDocument} from '../../types'
import {CurrentWorkspaceProvider} from '../form/CurrentWorkspaceProvider'
import {type FieldChange, trackFieldChanges} from './helpers/parseTransactions'
import {EditedAt} from './TaskActivityEditedAt'
import {TasksActivityCommentInput} from './TasksActivityCommentInput'
import {TasksActivityCreatedAt} from './TasksActivityCreatedAt'
import {ActivityItem} from './TasksActivityItem'
import {TasksSubscribers} from './TasksSubscribers'

function useActivityLog(task: TaskDocument) {
const [changes, setChanges] = useState<FieldChange[]>([])
const client = useClient({apiVersion: API_VERSION})
const {dataset, token} = client.config()

const queryParams = `tag=sanity.studio.tasks.history&effectFormat=mendoza&excludeContent=true&includeIdentifiedDocumentsOnly=true&reverse=true`
const transactionsUrl = client.getUrl(
`/data/history/${dataset}/transactions/${getPublishedId(task._id)}?${queryParams}`,
)

const fetchAndParse = useCallback(
async (newestTaskDocument: TaskDocument) => {
try {
const transactions: TransactionLogEventWithEffects[] = []

const stream = await getJsonStream(transactionsUrl, token)
const reader = stream.getReader()
let result
for (;;) {
result = await reader.read()
if (result.done) {
break
}
if ('error' in result.value) {
throw new Error(result.value.error.description || result.value.error.type)
}
transactions.push(result.value)
}

const fieldsToTrack: (keyof Omit<TaskDocument, '_rev'>)[] = [
'createdByUser',
'title',
'description',
'dueBy',
'assignedTo',
'status',
'target',
]

const parsedChanges = await trackFieldChanges(
newestTaskDocument,
[...transactions],
fieldsToTrack,
)

setChanges(parsedChanges)
} catch (error) {
console.error('Failed to fetch and parse activity log', error)
}
},
[transactionsUrl, token],
)

useEffect(() => {
fetchAndParse(task)
// Task is updated on every change, wait until the revision changes to update the activity log.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchAndParse, task._rev])
return {changes}
}

const EMPTY_ARRAY: [] = []

const VARIANTS: Variants = {
Expand All @@ -45,14 +119,6 @@ interface TasksActivityLogProps {
value: TaskDocument
}

interface ActivityLogItem {
author: string
field: string
from: string
timestamp: string
to?: string
}

type Activity =
| {
_type: 'comment'
Expand All @@ -61,7 +127,7 @@ type Activity =
}
| {
_type: 'activity'
payload: ActivityLogItem
payload: FieldChange
timestamp: string
}

Expand Down Expand Up @@ -148,13 +214,12 @@ export function TasksActivityLog(props: TasksActivityLogProps) {
[operation],
)

// TODO: Get the task real activity.
const activityData: ActivityLogItem[] = EMPTY_ARRAY
const activityData = useActivityLog(value).changes

const activity: Activity[] = useMemo(() => {
const taskActivity: Activity[] = activityData.map((item) => ({
_type: 'activity' as const,
payload: item as ActivityLogItem,
payload: item,
timestamp: item.timestamp,
}))
const commentsActivity: Activity[] = taskComments.map((comment) => ({
Expand Down Expand Up @@ -199,48 +264,45 @@ export function TasksActivityLog(props: TasksActivityLogProps) {
)}

{currentUser && (
<Stack space={4} marginTop={1}>
{taskComments.length > 0 && (
<Fragment>
{activity.map((item) => {
if (item._type === 'activity') {
return <EditedAt key={item.timestamp} activity={item.payload} />
}

return (
<ActivityItem
<CurrentWorkspaceProvider>
<Stack space={4} marginTop={1}>
{activity.map((item) => {
if (item._type === 'activity') {
return <EditedAt key={item.timestamp} activity={item.payload} />
}
return (
<ActivityItem
key={item.payload.parentComment._id}
userId={item.payload.parentComment.authorId}
>
<CommentsListItem
avatarConfig={COMMENTS_LIST_ITEM_AVATAR_CONFIG}
canReply
currentUser={currentUser}
innerPadding={1}
isSelected={false}
key={item.payload.parentComment._id}
userId={item.payload.parentComment.authorId}
>
<CommentsListItem
avatarConfig={COMMENTS_LIST_ITEM_AVATAR_CONFIG}
canReply
currentUser={currentUser}
innerPadding={1}
isSelected={false}
key={item.payload.parentComment._id}
mentionOptions={mentionOptions}
mode="default" // TODO: set dynamic mode?
onCreateRetry={handleCommentCreateRetry}
onDelete={handleCommentRemove}
onEdit={handleCommentEdit}
onReactionSelect={handleCommentReact}
onReply={handleCommentReply}
parentComment={item.payload.parentComment}
replies={item.payload.replies}
/>
</ActivityItem>
)
})}
</Fragment>
)}

<TasksActivityCommentInput
currentUser={currentUser}
mentionOptions={mentionOptions}
onSubmit={handleCommentCreate}
/>
</Stack>
mentionOptions={mentionOptions}
mode="default" // TODO: set dynamic mode?
onCreateRetry={handleCommentCreateRetry}
onDelete={handleCommentRemove}
onEdit={handleCommentEdit}
onReactionSelect={handleCommentReact}
onReply={handleCommentReply}
parentComment={item.payload.parentComment}
replies={item.payload.replies}
/>
</ActivityItem>
)
})}

<TasksActivityCommentInput
currentUser={currentUser}
mentionOptions={mentionOptions}
onSubmit={handleCommentCreate}
/>
</Stack>
</CurrentWorkspaceProvider>
)}
</MotionStack>
)}
Expand Down
Loading
Loading