Skip to content
16 changes: 10 additions & 6 deletions src/app/api/tasks/tasks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ export class TasksService extends BaseService {
return filteredTasks
}

async createTask(data: CreateTaskRequest, opts?: { isPublicApi?: boolean; disableSubtaskTemplates?: boolean }) {
async createTask(
data: CreateTaskRequest,
opts?: { isPublicApi?: boolean; disableSubtaskTemplates?: boolean; manualTimestamp?: Date },
) {
const policyGate = new PoliciesService(this.user)
policyGate.authorize(UserAction.Create, Resource.Tasks)
console.info('TasksService#createTask | Creating task with data:', data)
Expand Down Expand Up @@ -235,6 +238,7 @@ export class TasksService extends BaseService {
assigneeType,
viewers: viewers,
...validatedIds,
...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }),
...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)),
},
include: { workflowState: true },
Expand Down Expand Up @@ -305,9 +309,10 @@ export class TasksService extends BaseService {

if (template.subTaskTemplates.length) {
await Promise.all(
template.subTaskTemplates.map(async (sub) => {
template.subTaskTemplates.map(async (sub, index) => {
const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id)
await this.createSubtasksFromTemplate(updatedSubTemplate, newTask.id)
const manualTimeStamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates
await this.createSubtasksFromTemplate(updatedSubTemplate, newTask.id, manualTimeStamp)
}),
)
}
Expand Down Expand Up @@ -1149,7 +1154,7 @@ export class TasksService extends BaseService {
return viewers
}

private async createSubtasksFromTemplate(data: TaskTemplate, parentId: string) {
private async createSubtasksFromTemplate(data: TaskTemplate, parentId: string, manualTimestamp: Date) {
const { workspaceId, title, body, workflowStateId } = data
try {
const createTaskPayload = CreateTaskRequestSchema.parse({
Expand All @@ -1158,10 +1163,9 @@ export class TasksService extends BaseService {
workspaceId,
workflowStateId,
parentId,

templateId: undefined, //just to be safe from circular recursion
})
await this.createTask(createTaskPayload, { disableSubtaskTemplates: true })
await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp })
} catch (e) {
const deleteTask = this.db.task.delete({ where: { id: parentId } })
const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } })
Expand Down
5 changes: 5 additions & 0 deletions src/app/detail/[task_id]/[user_type]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AssigneeCacheGetter } from '@/app/_cache/AssigneeCacheGetter'
import { AssigneeFetcher } from '@/app/_fetchers/AssigneeFetcher'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { OneTaskDataFetcher } from '@/app/_fetchers/OneTaskDataFetcher'
import { TemplatesFetcher } from '@/app/_fetchers/TemplatesFetcher'
import { WorkflowStateFetcher } from '@/app/_fetchers/WorkflowStateFetcher'
import { UserRole } from '@/app/api/core/types/user'
import {
Expand Down Expand Up @@ -42,6 +43,7 @@ import EscapeHandler from '@/utils/escapeHandler'
import { getPreviewMode } from '@/utils/previewMode'
import { checkIfTaskViewer } from '@/utils/taskViewer'
import { Box, Stack } from '@mui/material'
import { Suspense } from 'react'
import { z } from 'zod'

async function getOneTask(token: string, taskId: string): Promise<TaskResponse | null> {
Expand Down Expand Up @@ -135,6 +137,9 @@ export default async function TaskDetailPage({
workspace={workspace}
>
{token && <OneTaskDataFetcher token={token} task_id={task_id} initialTask={task} />}
<Suspense fallback={null}>
<TemplatesFetcher token={token} />
</Suspense>
<RealTime tokenPayload={tokenPayload}>
<RealTimeTemplates tokenPayload={tokenPayload} token={token}>
<EscapeHandler />
Expand Down
4 changes: 3 additions & 1 deletion src/app/detail/ui/NewTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent'
import { PersonIconSmall, TempalteIconMd } from '@/icons'
import { selectAuthDetails } from '@/redux/features/authDetailsSlice'
import { selectTaskBoard } from '@/redux/features/taskBoardSlice'
import { selectTaskDetails } from '@/redux/features/taskDetailsSlice'
import { selectCreateTemplate } from '@/redux/features/templateSlice'
import { DateString } from '@/types/date'
import { CreateTaskRequest, Viewers } from '@/types/dto/tasks.dto'
Expand Down Expand Up @@ -53,6 +54,7 @@ export const NewTaskCard = ({
}) => {
const { workflowStates, assignee, token, activeTask, previewMode, previewClientCompany } = useSelector(selectTaskBoard)
const { templates } = useSelector(selectCreateTemplate)
const { fromNotificationCenter } = useSelector(selectTaskDetails)

const [isEditorReadonly, setIsEditorReadonly] = useState(false)

Expand Down Expand Up @@ -289,7 +291,7 @@ export const NewTaskCard = ({
placeholder="Search..."
value={templateValue}
selectorType={SelectorType.TEMPLATE_SELECTOR}
endOption={<ManageTemplatesEndOption hasTemplates={!!templates?.length} />}
endOption={!fromNotificationCenter && <ManageTemplatesEndOption hasTemplates={!!templates?.length} />}
endOptionHref={`/manage-templates?token=${token}`}
listAutoHeightMax="147px"
variant="normal"
Expand Down
2 changes: 1 addition & 1 deletion src/app/detail/ui/Subtasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { generateRandomString } from '@/utils/generateRandomString'
import { checkOptimisticStableId } from '@/utils/optimisticCommentUtils'
import { getTempTask } from '@/utils/optimisticTaskUtils'
import { sortTaskByDescendingOrder } from '@/utils/sortTask'
import { sortTaskByDescendingOrder } from '@/utils/sortByDescending'
import { Box, Stack, Typography } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
Expand Down Expand Up @@ -75,7 +75,7 @@
debounceMutate(cacheKey)
}
setLastUpdated(activeTask?.lastSubtaskUpdated)
}, [activeTask?.lastSubtaskUpdated])

Check warning on line 78 in src/app/detail/ui/Subtasks.tsx

View workflow job for this annotation

GitHub Actions / Run linters

React Hook useEffect has missing dependencies: 'activeTask', 'cacheKey', 'debounceMutate', and 'lastUpdated'. Either include them or remove the dependency array

const handleSubTaskCreation = (payload: CreateTaskRequest) => {
const tempId = generateRandomString('temp-task')
Expand Down
11 changes: 9 additions & 2 deletions src/app/manage-templates/[template_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ManageTemplateDetailsAppBridge } from '@/app/manage-templates/ui/Manage
import { DeletedRedirectPage } from '@/components/layouts/DeletedRedirectPage'
import { OneTemplateDataFetcher } from '@/app/_fetchers/OneTemplateDataFetcher'
import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin'
import { getPreviewMode } from '@/utils/previewMode'

async function getTemplate(id: string, token: string): Promise<ITemplate> {
const res = await fetch(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, {
Expand Down Expand Up @@ -62,6 +63,8 @@ export default async function TaskDetailPage({
})),
]

const isPreviewMode = !!getPreviewMode(tokenPayload)

return (
<ClientSideStateUpdate workflowStates={workflowStates} token={token} template={template} tokenPayload={tokenPayload}>
{token && <OneTemplateDataFetcher token={token} template_id={template_id} initialTemplate={template} />}
Expand All @@ -70,9 +73,13 @@ export default async function TaskDetailPage({
<ResponsiveStack fromNotificationCenter={false}>
<Box sx={{ width: '100%', display: 'flex', flex: 1, flexDirection: 'column', overflow: 'auto' }}>
<StyledBox>
<AppMargin size={SizeofAppMargin.HEADER} py="17.5px">
{isPreviewMode ? (
<AppMargin size={SizeofAppMargin.HEADER} py="17.5px">
<HeaderBreadcrumbs token={token} items={breadcrumbItems} userType={UserType.INTERNAL_USER} />
</AppMargin>
) : (
<HeaderBreadcrumbs token={token} items={breadcrumbItems} userType={UserType.INTERNAL_USER} />
</AppMargin>
)}
</StyledBox>
<ManageTemplateDetailsAppBridge portalUrl={workspace.portalUrl} template={template} />
<TaskDetailsContainer
Expand Down
2 changes: 1 addition & 1 deletion src/app/manage-templates/ui/Subtemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { GrayAddMediumIcon } from '@/icons'
import { fetcher } from '@/utils/fetcher'
import { checkOptimisticStableId } from '@/utils/optimisticCommentUtils'
import { sortTaskByDescendingOrder } from '@/utils/sortTask'
import { sortTaskByDescendingOrder } from '@/utils/sortByDescending'
import { Box, Stack, Typography } from '@mui/material'
import useSWR, { useSWRConfig } from 'swr'
import { createSubTemplate } from '@/app/manage-templates/actions'
Expand Down Expand Up @@ -66,7 +66,7 @@
}

debounceMutate(cacheKey)
}, [activeTemplate?.subTaskTemplates])

Check warning on line 69 in src/app/manage-templates/ui/Subtemplates.tsx

View workflow job for this annotation

GitHub Actions / Run linters

React Hook useEffect has missing dependencies: 'activeTemplate', 'cacheKey', and 'debounceMutate'. Either include them or remove the dependency array

const handleSubtemplateCreation = (payload: CreateTemplateRequest) => {
const tempId = generateRandomString('temp-template')
Expand Down
10 changes: 5 additions & 5 deletions src/app/manage-templates/ui/TemplateBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { Box, Stack, Typography } from '@mui/material'
import { useSelector } from 'react-redux'
import { NoTemplateLayout } from './NoTemplateLayout'
import { TemplateForm } from './TemplateForm'
import { sortTemplatesByDescendingOrder } from '@/utils/sortByDescending'
import { useMemo } from 'react'

export const TemplateBoard = ({
handleCreateTemplate,
Expand All @@ -27,17 +29,15 @@ export const TemplateBoard = ({
useSelector(selectCreateTemplate)

const { token, previewMode } = useSelector(selectTaskBoard)
const sortedTemplates = useMemo(() => sortTemplatesByDescendingOrder(templates), [templates])

if (templates === undefined) {
return null
}
const showHeader = token && !!previewMode

return (
<>
{showHeader && <ManageTemplateHeader token={token} />}

{templates.length ? (
{sortedTemplates.length ? (
<Box id="templates-box" sx={{ maxWidth: '384px', marginTop: '32px', marginLeft: 'auto', marginRight: 'auto' }}>
<Box
sx={{
Expand All @@ -59,7 +59,7 @@ export const TemplateBoard = ({
}}
rowGap={4}
>
{templates.map((template) => {
{sortedTemplates.map((template) => {
return (
<CustomLink
key={template.id}
Expand Down
13 changes: 10 additions & 3 deletions src/app/ui/NewTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) =>
handleCreate={handleCreateWithAssignee}
handleClose={handleClose}
updateWorkflowStatusValue={updateStatusValue}
creationDisabled={isEditorReadonly}
/>
</NewTaskContainer>
)
Expand Down Expand Up @@ -619,7 +620,7 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => {
deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)}
attachmentLayout={AttachmentLayout}
maxUploadLimit={MAX_UPLOAD_LIMIT}
parentContainerStyle={{ gap: '0px', height: '66px' }}
parentContainerStyle={{ gap: '0px', minHeight: '60px' }}
/>
</Box>
</Stack>
Expand All @@ -630,7 +631,8 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => {
const NewTaskFooter = ({
handleCreate,
handleClose,
}: NewTaskFormProps & { updateWorkflowStatusValue: (value: unknown) => void }) => {
creationDisabled,
}: NewTaskFormProps & { updateWorkflowStatusValue: (value: unknown) => void; creationDisabled: boolean }) => {
const { title } = useSelector(selectCreateTask)

return (
Expand All @@ -654,7 +656,12 @@ const NewTaskFooter = ({
</Typography>
}
/>
<PrimaryBtn padding="3px 8px" disabled={!title.trim()} handleClick={handleCreate} buttonText="Create" />
<PrimaryBtn
padding="3px 8px"
disabled={!title.trim() || creationDisabled}
handleClick={handleCreate}
buttonText="Create"
/>
</Stack>
</Stack>
</AppMargin>
Expand Down
2 changes: 1 addition & 1 deletion src/app/ui/TaskBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import store from '@/redux/store'
import { WorkspaceResponse } from '@/types/common'
import { TaskResponse } from '@/types/dto/tasks.dto'
import { View } from '@/types/interfaces'
import { sortTaskByDescendingOrder } from '@/utils/sortTask'
import { sortTaskByDescendingOrder } from '@/utils/sortByDescending'
import { prioritizeStartedStates } from '@/utils/workflowStates'
import { UserRole } from '@api/core/types/user'
import { Box, Stack } from '@mui/material'
Expand Down
7 changes: 3 additions & 4 deletions src/hoc/RealtimeTemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { selectCreateTemplate, setActiveTemplate, setTemplates } from '@/redux/f
import store from '@/redux/store'
import { Token } from '@/types/common'
import { ITemplate } from '@/types/interfaces'
import { getFormattedTemplate } from '@/utils/getFormattedRealTimeData'
import { isTemplatePayloadEqual } from '@/utils/isRealtimePayloadEqual'
import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer'
import { RealtimePostgresChangesPayload } from '@supabase/supabase-js'
Expand Down Expand Up @@ -74,7 +75,7 @@ export const RealTimeTemplates = ({
return //no changes for the same payload
}
if (payload.eventType === 'INSERT') {
const newTemplate = payload.new
const newTemplate = getFormattedTemplate(payload.new)
let canUserAccessTask = newTemplate.workspaceId === tokenPayload.workspaceId
if (!canUserAccessTask) return
if (newTemplate?.parentId) {
Expand All @@ -86,9 +87,7 @@ export const RealTimeTemplates = ({
: store.dispatch(setTemplates([{ ...newTemplate }]))
}
if (payload.eventType === 'UPDATE') {
const updatedTemplate = payload.new

updatedTemplate.updatedAt = updatedTemplate.updatedAt
const updatedTemplate = getFormattedTemplate(payload.new)

const oldTemplate = templates && templates.find((template) => template.id == updatedTemplate.id)
if (payload.new.workspaceId === tokenPayload.workspaceId) {
Expand Down
30 changes: 10 additions & 20 deletions src/lib/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import store from '@/redux/store'
import { InternalUsersSchema, Token } from '@/types/common'
import { TaskResponse } from '@/types/dto/tasks.dto'
import { IAssigneeCombined } from '@/types/interfaces'
import { getFormattedTask } from '@/utils/getFormattedRealTimeData'
import { getPreviewMode } from '@/utils/previewMode'
import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer'
import { AssigneeType } from '@prisma/client'
Expand All @@ -20,27 +21,13 @@ export class RealtimeHandler {
private readonly redirectToBoard: (newTask: RealTimeTaskResponse) => void,
private readonly tokenPayload: Token,
) {
const newTask = this.getFormattedTask(this.payload.new)
const newTask = getFormattedTask(this.payload.new)
if (newTask.workspaceId !== tokenPayload.workspaceId) {
console.error('Realtime event ignored for task with different workspaceId')
return
}
}

private getFormattedTask(task: unknown): RealTimeTaskResponse {
const newTask = task as RealTimeTaskResponse
// NOTE: we append a Z here to make JS understand this raw timestamp (in format YYYY-MM-DD:HH:MM:SS.MS) is in UTC timezone
// New payloads listened on the 'INSERT' action in realtime doesn't contain this tz info so the order can mess up,
// causing tasks to bounce around on hover
return {
...newTask,
createdAt: newTask.createdAt && new Date(newTask.createdAt + 'Z').toISOString(),
updatedAt: newTask.updatedAt && new Date(newTask.updatedAt + 'Z').toISOString(),
lastActivityLogUpdated: newTask.lastActivityLogUpdated && new Date(newTask.lastActivityLogUpdated + 'Z').toISOString(),
lastSubtaskUpdated: newTask.lastSubtaskUpdated && new Date(newTask.lastSubtaskUpdated + 'Z').toISOString(),
}
}

private isViewer(newTask: RealTimeTaskResponse): boolean {
return this.tokenPayload.clientId || !!getPreviewMode(this.tokenPayload)
? (newTask.viewers?.some(
Expand Down Expand Up @@ -188,7 +175,7 @@ export class RealtimeHandler {
const currentState = store.getState()
const { tasks, accessibleTasks } = selectTaskBoard(currentState)

const newTask = this.getFormattedTask(this.payload.new)
const newTask = getFormattedTask(this.payload.new)

// Being a subtask, this surely has a valid non-null parentId
newTask.parentId = z.string().parse(newTask.parentId)
Expand All @@ -206,7 +193,9 @@ export class RealtimeHandler {
return this.handleRealtimeSubtaskInsert(newTask)
}
if (this.payload.eventType === 'UPDATE') {
return this.handleRealtimeSubtaskUpdate(newTask)
return setTimeout(() => {
this.handleRealtimeSubtaskUpdate(newTask)
}, 0) //avoid race condition causing duplicate data when update is triggered before create.
}
console.error('Unknown event type for realtime subtask handler')
}
Expand All @@ -215,7 +204,7 @@ export class RealtimeHandler {
* Handler for realtime task inserts
*/
handleRealtimeTaskInsert() {
const newTask = this.getFormattedTask(this.payload.new)
const newTask = getFormattedTask(this.payload.new)

const commonStore = store.getState()
const { accessibleTasks, showUnarchived, tasks } = commonStore.taskBoard
Expand Down Expand Up @@ -265,8 +254,9 @@ export class RealtimeHandler {
* Handler for realtime task update events
*/
handleRealtimeTaskUpdate() {
const updatedTask = this.getFormattedTask(this.payload.new)
const prevTask = this.getFormattedTask(this.payload.old)
const updatedTask = getFormattedTask(this.payload.new)
const prevTask = getFormattedTask(this.payload.old)

const commonStore = store.getState()
const { activeTask, accessibleTasks, showArchived, showUnarchived, tasks } = commonStore.taskBoard

Expand Down
4 changes: 2 additions & 2 deletions src/types/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ export interface ITemplate {
body: string
workflowStateId: string
createdBy: string
createdAt: Date
updatedAt: Date
createdAt: string
updatedAt: string
parentId: string | null
subTaskTemplates: ITemplate[]
parent?: ITemplate | null
Expand Down
30 changes: 30 additions & 0 deletions src/utils/getFormattedRealTimeData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { RealTimeTaskResponse } from '@/hoc/RealTime'
import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates'

type TimestampKeys<T> = Extract<keyof T, string>

function formatTimestamps<T extends Record<string, any>>(obj: T, keys: TimestampKeys<T>[]): T {
const formatted: Partial<T> = { ...obj }

keys.forEach((key) => {
const value = obj[key]
if (typeof value === 'string') {
formatted[key] = new Date(value + 'Z').toISOString() as any
}
})

return formatted as T
}

export function getFormattedTask(task: unknown): RealTimeTaskResponse {
return formatTimestamps<RealTimeTaskResponse>(task as RealTimeTaskResponse, [
'createdAt',
'updatedAt',
'lastActivityLogUpdated',
'lastSubtaskUpdated',
])
}

export function getFormattedTemplate(template: unknown): RealTimeTemplateResponse {
return formatTimestamps<RealTimeTemplateResponse>(template as RealTimeTemplateResponse, ['createdAt', 'updatedAt'])
}
Loading
Loading