diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 9cc4584b6..55c8a8f07 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -24,6 +24,7 @@ import { UserRole } from '@api/core/types/user' import { Suspense } from 'react' import { z } from 'zod' import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' +import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' export async function getAllWorkflowStates(token: string): Promise { const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { @@ -133,15 +134,17 @@ export default async function Main({ - - - - { - 'use server' - await createMultipleAttachments(token, attachments) - }} - /> + + + + + { + 'use server' + await createMultipleAttachments(token, attachments) + }} + /> + diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 69c606b80..104162bdf 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -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) @@ -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 }, @@ -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) }), ) } @@ -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({ @@ -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 } }) diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index 4f115a41d..c5c2e6155 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -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 { @@ -32,6 +33,7 @@ import { apiUrl } from '@/config' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' import CustomScrollBar from '@/hoc/CustomScrollBar' import { RealTime } from '@/hoc/RealTime' +import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' import { AncestorTaskResponse, SubTaskStatusResponse, TaskResponse } from '@/types/dto/tasks.dto' import { UserType } from '@/types/interfaces' @@ -41,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 { @@ -134,132 +137,137 @@ export default async function TaskDetailPage({ workspace={workspace} > {token && } + + + - - - - {isPreviewMode ? ( - - - - - - + + + + + {isPreviewMode ? ( + + + + - + + + + - - - - ) : ( - <> - - - - )} - - - - - - { - 'use server' - await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) - }} - updateTaskTitle={async (title) => { - 'use server' - title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) - }} - deleteTask={async () => { - 'use server' - await deleteTask(token, task_id) - }} - postAttachment={async (postAttachmentPayload) => { - 'use server' - await postAttachment(token, postAttachmentPayload) - }} - deleteAttachment={async (id: string) => { - 'use server' - await deleteAttachment(token, id) - }} - userType={params.user_type} - token={token} - /> - - {subTaskStatus.canCreateSubtask && ( - + + ) : ( + <> + - )} + + + )} + + + + + + { + 'use server' + await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) + }} + updateTaskTitle={async (title) => { + 'use server' + title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) + }} + deleteTask={async () => { + 'use server' + await deleteTask(token, task_id) + }} + postAttachment={async (postAttachmentPayload) => { + 'use server' + await postAttachment(token, postAttachmentPayload) + }} + deleteAttachment={async (id: string) => { + 'use server' + await deleteAttachment(token, id) + }} + userType={params.user_type} + token={token} + /> + + {subTaskStatus.canCreateSubtask && ( + + )} - - - - - - - - - { - 'use server' - params.user_type === UserType.CLIENT_USER && !getPreviewMode(tokenPayload) - ? await clientUpdateTask(token, task_id, workflowState.id) - : await updateWorkflowStateIdOfTask(token, task_id, workflowState?.id) - }} - updateAssignee={async ({ internalUserId, clientId, companyId, viewers }: UserIdsWithViewersType) => { - 'use server' - await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) - }} - updateTask={async (payload) => { - 'use server' - await updateTaskDetail({ token, taskId: task_id, payload }) - }} - disabled={params.user_type === UserType.CLIENT_USER} - workflowDisabled={isViewer} - /> - - + + + + + + + + + { + 'use server' + params.user_type === UserType.CLIENT_USER && !getPreviewMode(tokenPayload) + ? await clientUpdateTask(token, task_id, workflowState.id) + : await updateWorkflowStateIdOfTask(token, task_id, workflowState?.id) + }} + updateAssignee={async ({ internalUserId, clientId, companyId, viewers }: UserIdsWithViewersType) => { + 'use server' + await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) + }} + updateTask={async (payload) => { + 'use server' + await updateTaskDetail({ token, taskId: task_id, payload }) + }} + disabled={params.user_type === UserType.CLIENT_USER} + workflowDisabled={isViewer} + /> + + + ) diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index 713dfa97f..a2cf03be5 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -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' @@ -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) @@ -289,7 +291,7 @@ export const NewTaskCard = ({ placeholder="Search..." value={templateValue} selectorType={SelectorType.TEMPLATE_SELECTOR} - endOption={} + endOption={!fromNotificationCenter && } endOptionHref={`/manage-templates?token=${token}`} listAutoHeightMax="147px" variant="normal" diff --git a/src/app/detail/ui/Subtasks.tsx b/src/app/detail/ui/Subtasks.tsx index da135df7f..1ae25a735 100644 --- a/src/app/detail/ui/Subtasks.tsx +++ b/src/app/detail/ui/Subtasks.tsx @@ -16,7 +16,7 @@ import { fetcher } from '@/utils/fetcher' 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' diff --git a/src/app/manage-templates/[template_id]/page.tsx b/src/app/manage-templates/[template_id]/page.tsx index 9bbc1f7be..756500abd 100644 --- a/src/app/manage-templates/[template_id]/page.tsx +++ b/src/app/manage-templates/[template_id]/page.tsx @@ -9,13 +9,15 @@ import { Box } from '@mui/material' import TemplateDetails from '@/app/manage-templates/ui/TemplateDetails' import { deleteTemplate, editTemplate } from '@/app/manage-templates/actions' import { UpdateTemplateRequest } from '@/types/dto/templates.dto' -import { StyledTiptapDescriptionWrapper, TaskDetailsContainer } from '@/app/detail/ui/styledComponent' +import { StyledBox, StyledTiptapDescriptionWrapper, TaskDetailsContainer } from '@/app/detail/ui/styledComponent' import { TemplateSidebar } from '@/app/manage-templates/ui/TemplateSidebar' import { Subtemplates } from '@/app/manage-templates/ui/Subtemplates' import { HeaderBreadcrumbs } from '@/components/layouts/HeaderBreadcrumbs' import { ManageTemplateDetailsAppBridge } from '@/app/manage-templates/ui/ManageTemplatesDetailsAppBridge' 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 { const res = await fetch(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, { @@ -61,6 +63,8 @@ export default async function TaskDetailPage({ })), ] + const isPreviewMode = !!getPreviewMode(tokenPayload) + return ( {token && } @@ -68,7 +72,15 @@ export default async function TaskDetailPage({ - + + {isPreviewMode ? ( + + + + ) : ( + + )} + sortTemplatesByDescendingOrder(templates), [templates]) - if (templates === undefined) { - return null - } const showHeader = token && !!previewMode return ( <> {showHeader && } - {templates.length ? ( + {sortedTemplates.length ? ( - {templates.map((template) => { + {sortedTemplates.map((template) => { return ( handleCreate={handleCreateWithAssignee} handleClose={handleClose} updateWorkflowStatusValue={updateStatusValue} + creationDisabled={isEditorReadonly} /> ) @@ -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' }} /> @@ -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 ( @@ -654,7 +656,12 @@ const NewTaskFooter = ({ } /> - + diff --git a/src/app/ui/TaskBoard.tsx b/src/app/ui/TaskBoard.tsx index 387fb58a3..6c5334cec 100644 --- a/src/app/ui/TaskBoard.tsx +++ b/src/app/ui/TaskBoard.tsx @@ -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' diff --git a/src/components/layouts/HeaderBreadcrumbs.tsx b/src/components/layouts/HeaderBreadcrumbs.tsx index 20053a3bb..00ab5364c 100644 --- a/src/components/layouts/HeaderBreadcrumbs.tsx +++ b/src/components/layouts/HeaderBreadcrumbs.tsx @@ -47,7 +47,6 @@ export const HeaderBreadcrumbs = ({ if (!previewMode) { return null } - return ( @@ -60,19 +59,34 @@ export const HeaderBreadcrumbs = ({ variant="breadcrumb" /> - {items.map((item) => ( - - - - {item.label} - - - ))} + {items.map((item, index) => { + const isLast = index === items.length - 1 + + return ( + + {isLast ? ( + <> + + + {item.label} + + + ) : ( + + + + {item.label} + + } + variant="breadcrumb" + /> + + )} + + ) + })} ) } diff --git a/src/hoc/RealTime.tsx b/src/hoc/RealTime.tsx index 240101b23..1d935468e 100644 --- a/src/hoc/RealTime.tsx +++ b/src/hoc/RealTime.tsx @@ -6,9 +6,9 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectTaskDetails } from '@/redux/features/taskDetailsSlice' import { Token } from '@/types/common' import { TaskResponse } from '@/types/dto/tasks.dto' +import { isTaskPayloadEqual } from '@/utils/isRealtimePayloadEqual' import { AssigneeType } from '@prisma/client' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' -import deepEqual from 'deep-equal' import { usePathname, useRouter } from 'next/navigation' import { ReactNode, useEffect } from 'react' import { useSelector } from 'react-redux' @@ -60,15 +60,8 @@ export const RealTime = ({ } } - function isPayloadEqual(payload: RealtimePostgresChangesPayload): boolean { - const newPayload = payload.new - const oldPayload = payload.old - if (!newPayload || !oldPayload) return true - return deepEqual(newPayload, oldPayload) - } - const handleRealtimeEvents = (payload: RealtimePostgresChangesPayload) => { - if (isPayloadEqual(payload)) { + if (isTaskPayloadEqual(payload)) { return //no changes for the same payload } const user = assignee.find((el) => el.id === userId) diff --git a/src/hoc/RealtimeTemplates.tsx b/src/hoc/RealtimeTemplates.tsx index a4757f97e..c0c83d148 100644 --- a/src/hoc/RealtimeTemplates.tsx +++ b/src/hoc/RealtimeTemplates.tsx @@ -5,13 +5,15 @@ 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' import { usePathname, useRouter } from 'next/navigation' import { ReactNode, useEffect } from 'react' import { useSelector } from 'react-redux' -interface RealTimeTemplateResponse extends ITemplate { +export interface RealTimeTemplateResponse extends ITemplate { deletedAt: string } @@ -31,7 +33,7 @@ export const RealTimeTemplates = ({ const pathname = usePathname() const router = useRouter() - const applySubtemplateToActiveTemplate = (newTemplate: RealTimeTemplateResponse) => { + const applySubtemplateToParentTemplate = (newTemplate: RealTimeTemplateResponse) => { if (!newTemplate?.parentId) return if (activeTemplate?.id === newTemplate.parentId) { @@ -42,6 +44,19 @@ export const RealTimeTemplates = ({ }), ) } + + store.dispatch( + setTemplates( + templates.map((template) => + template.id === newTemplate.parentId + ? { + ...template, + subTaskTemplates: [...(template.subTaskTemplates || []), newTemplate], + } + : template, + ), + ), + ) //also append the subTaskTemplates to parent template on the templates store. } const redirectBack = (updatedTemplate: RealTimeTemplateResponse) => { @@ -56,12 +71,15 @@ export const RealTimeTemplates = ({ } const handleTemplatesRealTimeUpdates = (payload: RealtimePostgresChangesPayload) => { + if (isTemplatePayloadEqual(payload)) { + 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) { - applySubtemplateToActiveTemplate(newTemplate) + applySubtemplateToParentTemplate(newTemplate) return } templates @@ -69,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) { @@ -105,7 +121,7 @@ export const RealTimeTemplates = ({ ) } if (updatedTemplate?.parentId) { - applySubtemplateToActiveTemplate(updatedTemplate) + applySubtemplateToParentTemplate(updatedTemplate) return } const newTemplateArr = [ diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts index c23db3c26..a4c0123a5 100644 --- a/src/lib/realtime.ts +++ b/src/lib/realtime.ts @@ -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' @@ -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( @@ -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) @@ -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') } @@ -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 @@ -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 diff --git a/src/lib/supabase-privilege/grant-all-privileges.sql b/src/lib/supabase-privilege/grant-all-privileges.sql index 94d8431f9..7ef9da8e1 100644 --- a/src/lib/supabase-privilege/grant-all-privileges.sql +++ b/src/lib/supabase-privilege/grant-all-privileges.sql @@ -11,3 +11,5 @@ alter default privileges in schema public grant all on sequences to postgres, an alter table "Tasks" replica identity full; +alter table + "TaskTemplates" replica identity full; diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index dce8e767f..c96f072df 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -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 diff --git a/src/utils/getFormattedRealTimeData.ts b/src/utils/getFormattedRealTimeData.ts new file mode 100644 index 000000000..a777f4246 --- /dev/null +++ b/src/utils/getFormattedRealTimeData.ts @@ -0,0 +1,30 @@ +import { RealTimeTaskResponse } from '@/hoc/RealTime' +import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' + +type TimestampKeys = Extract + +function formatTimestamps>(obj: T, keys: TimestampKeys[]): T { + const formatted: Partial = { ...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(task as RealTimeTaskResponse, [ + 'createdAt', + 'updatedAt', + 'lastActivityLogUpdated', + 'lastSubtaskUpdated', + ]) +} + +export function getFormattedTemplate(template: unknown): RealTimeTemplateResponse { + return formatTimestamps(template as RealTimeTemplateResponse, ['createdAt', 'updatedAt']) +} diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts new file mode 100644 index 000000000..6ef32e0c5 --- /dev/null +++ b/src/utils/isRealtimePayloadEqual.ts @@ -0,0 +1,31 @@ +import { RealTimeTaskResponse } from '@/hoc/RealTime' +import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' +import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' +import deepEqual from 'deep-equal' + +export function isTaskPayloadEqual( + payload: RealtimePostgresChangesPayload, +): boolean { + const newPayload = payload.new + const oldPayload = payload.old + if (!newPayload || !oldPayload) return true + return deepEqual(newPayload, oldPayload) +} + +export function isTemplatePayloadEqual(payload: RealtimePostgresChangesPayload): boolean { + const { new: n, old: o } = payload + + const hasRequiredFields = (obj: {} | RealTimeTemplateResponse): obj is RealTimeTemplateResponse => + typeof obj === 'object' && + obj !== null && + 'title' in obj && + 'body' in obj && + 'workflowStateId' in obj && + 'deletedAt' in obj + + if (!hasRequiredFields(n) || !hasRequiredFields(o)) { + return false + } + + return n.title === o.title && n.body === o.body && n.workflowStateId === o.workflowStateId && n.deletedAt === o.deletedAt +} diff --git a/src/utils/optimisticTaskUtils.ts b/src/utils/optimisticTaskUtils.ts index c96f8ef52..cd0e4c0cb 100644 --- a/src/utils/optimisticTaskUtils.ts +++ b/src/utils/optimisticTaskUtils.ts @@ -54,8 +54,8 @@ export const getTempTaskTemplate = ( body: payload.body ?? '', workflowStateId: payload.workflowStateId, createdBy: userId, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), parentId: parentId, subTaskTemplates: [], } diff --git a/src/utils/sortByDescending.ts b/src/utils/sortByDescending.ts new file mode 100644 index 000000000..621961286 --- /dev/null +++ b/src/utils/sortByDescending.ts @@ -0,0 +1,35 @@ +interface BaseSortable { + createdAt: string + id: string +} + +interface WithDueDate extends BaseSortable { + dueDate?: string +} + +const getTimestamp = (date: string | Date) => new Date(date).getTime() + +export const sortByDescendingOrder = ( + items: T[], + priorityKey?: K, +): T[] => { + return [...items].sort((a, b) => { + if (priorityKey) { + const aVal = a[priorityKey] as unknown as string | undefined + const bVal = b[priorityKey] as unknown as string | undefined + + if (aVal && !bVal) return -1 + if (bVal && !aVal) return 1 + if (aVal && bVal && aVal !== bVal) { + return getTimestamp(aVal) - getTimestamp(bVal) + } + } + + const createdAtDiff = getTimestamp(b.createdAt) - getTimestamp(a.createdAt) + return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) + }) +} + +export const sortTaskByDescendingOrder = (tasks: T[]) => sortByDescendingOrder(tasks, 'dueDate') + +export const sortTemplatesByDescendingOrder = (templates: T[]) => sortByDescendingOrder(templates) diff --git a/src/utils/sortTask.ts b/src/utils/sortTask.ts deleted file mode 100644 index 08e4e4fb8..000000000 --- a/src/utils/sortTask.ts +++ /dev/null @@ -1,25 +0,0 @@ -interface Sortable { - dueDate?: string - createdAt: string - id: string -} - -const getTimestamp = (date: string | Date) => new Date(date).getTime() - -export const sortTaskByDescendingOrder = (tasks: T[]): T[] => { - return tasks.sort((a, b) => { - // Prioritize tasks with due dates over tasks without due dates - if (a.dueDate && !b.dueDate) { - return -1 - } else if (b.dueDate && !a.dueDate) { - return 1 - } else if (a.dueDate && b.dueDate) { - // Sort by duedate in asc order. - if (a.dueDate !== b.dueDate) { - return getTimestamp(a.dueDate) - getTimestamp(b.dueDate) - } - } - const createdAtDiff = getTimestamp(b.createdAt) - getTimestamp(a.createdAt) - return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) - }) -}