From 62548e5c37f04df23424ebfc6cbc3d22cab90178 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 14 May 2025 15:41:09 +0530 Subject: [PATCH 1/7] feat(task-drawer): add recurring task configuration Add support for configuring recurring tasks in the task drawer. This includes adding a new `schedule_id` field to the task type, creating a new `TaskDrawerRecurringConfig` component, and updating localization files for English, Spanish, and Portuguese. The configuration allows setting repeat intervals, days of the week, and monthly recurrence options. --- .../en/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../es/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../pt/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../task-drawer-recurring-config.tsx | 245 ++++++++++++++++++ .../shared/info-tab/task-details-form.tsx | 5 + .../types/tasks/task-recurring-schedule.ts | 19 ++ .../src/types/tasks/task.types.ts | 1 + 10 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx create mode 100644 worklenz-frontend/src/types/tasks/task-recurring-schedule.ts diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json index 42ffdc83..b5caeb72 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Hide Start Date", "show-start-date": "Show Start Date", "hours": "Hours", - "minutes": "Minutes" + "minutes": "Minutes", + "recurring": "Recurring" }, "description": { "title": "Description", diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..f1d0301d --- /dev/null +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurring", + "recurringTaskConfiguration": "Recurring task configuration", + "repeats": "Repeats", + "weekly": "Weekly", + "everyXDays": "Every X Days", + "everyXWeeks": "Every X Weeks", + "everyXMonths": "Every X Months", + "monthly": "Monthly", + "selectDaysOfWeek": "Select Days of the Week", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun", + "monthlyRepeatType": "Monthly repeat type", + "onSpecificDate": "On a specific date", + "onSpecificDay": "On a specific day", + "dateOfMonth": "Date of the month", + "weekOfMonth": "Week of the month", + "dayOfWeek": "Day of the week", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last", + "intervalDays": "Interval (days)", + "intervalWeeks": "Interval (weeks)", + "intervalMonths": "Interval (months)", + "saveChanges": "Save Changes" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json index 58c5715e..cdafd81c 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recurrente" }, "description": { "title": "Descripción", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..d9c711a5 --- /dev/null +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurrente", + "recurringTaskConfiguration": "Configuración de tarea recurrente", + "repeats": "Repeticiones", + "weekly": "Semanal", + "everyXDays": "Cada X días", + "everyXWeeks": "Cada X semanas", + "everyXMonths": "Cada X meses", + "monthly": "Mensual", + "selectDaysOfWeek": "Seleccionar días de la semana", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetición mensual", + "onSpecificDate": "En una fecha específica", + "onSpecificDay": "En un día específico", + "dateOfMonth": "Fecha del mes", + "weekOfMonth": "Semana del mes", + "dayOfWeek": "Día de la semana", + "first": "Primero", + "second": "Segundo", + "third": "Tercero", + "fourth": "Cuarto", + "last": "Último", + "intervalDays": "Intervalo (días)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Guardar cambios" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json index 48922a52..fde2215a 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recorrente" }, "description": { "title": "Descrição", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..5619884b --- /dev/null +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recorrente", + "recurringTaskConfiguration": "Configuração de tarefa recorrente", + "repeats": "Repete", + "weekly": "Semanal", + "everyXDays": "A cada X dias", + "everyXWeeks": "A cada X semanas", + "everyXMonths": "A cada X meses", + "monthly": "Mensal", + "selectDaysOfWeek": "Selecionar dias da semana", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetição mensal", + "onSpecificDate": "Em uma data específica", + "onSpecificDay": "Em um dia específico", + "dateOfMonth": "Data do mês", + "weekOfMonth": "Semana do mês", + "dayOfWeek": "Dia da semana", + "first": "Primeira", + "second": "Segunda", + "third": "Terceira", + "fourth": "Quarta", + "last": "Última", + "intervalDays": "Intervalo (dias)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Salvar alterações" +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx new file mode 100644 index 00000000..56daee49 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -0,0 +1,245 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Form, Switch, Button, Popover, Select, Checkbox, Radio, InputNumber, Skeleton, Row, Col } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { ITaskViewModel } from '@/types/tasks/task.types'; +import { useTranslation } from 'react-i18next'; + +// Dummy enums and types for demonstration; replace with actual imports/types +const ITaskRecurring = { + Weekly: 'weekly', + EveryXDays: 'every_x_days', + EveryXWeeks: 'every_x_weeks', + EveryXMonths: 'every_x_months', + Monthly: 'monthly', +}; + +const repeatOptions = [ + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, +]; + +const daysOfWeek = [ + { label: 'Mon', value: 'mon' }, + { label: 'Tue', value: 'tue' }, + { label: 'Wed', value: 'wed' }, + { label: 'Thu', value: 'thu' }, + { label: 'Fri', value: 'fri' }, + { label: 'Sat', value: 'sat' }, + { label: 'Sun', value: 'sun' }, +]; + +const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const weekOptions = [ + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth', value: 'fourth' }, + { label: 'Last', value: 'last' }, +]; +const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + +const TaskDrawerRecurringConfig = ({ task }: {task: ITaskViewModel}) => { + const { socket, connected } = useSocket(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + + const [recurring, setRecurring] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [selectedDays, setSelectedDays] = useState([]); + const [monthlyOption, setMonthlyOption] = useState('date'); + const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); + const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); + const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); + const [intervalDays, setIntervalDays] = useState(1); + const [intervalWeeks, setIntervalWeeks] = useState(1); + const [intervalMonths, setIntervalMonths] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const [updatingData, setUpdatingData] = useState(false); + + const handleChange = (checked: boolean) => { + setRecurring(checked); + if (!checked) setShowConfig(false); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo(() => repeatOption.value === ITaskRecurring.Monthly, [repeatOption]); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => { + + }; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + } + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse) + }, []) + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ label: date.toString(), value: date }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); +}; + +export default TaskDrawerRecurringConfig; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index f9792485..a2dcaef1 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -29,6 +29,7 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress'; import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; +import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => + + + + diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts new file mode 100644 index 00000000..8fc708d5 --- /dev/null +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -0,0 +1,19 @@ +export interface ITaskRecurringSchedule { + type: 'daily' | 'weekly' | 'monthly' | 'interval'; + dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) + dayOfMonth?: number; // 1 - 31 (for monthly tasks) + weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) + hour: number; // Time of the day in 24-hour format + minute: number; // Minute of the hour + interval?: { + days?: number; // Interval in days (for every x days) + weeks?: number; // Interval in weeks (for every x weeks) + months?: number; // Interval in months (for every x months) + }; +} + +export interface ITaskRecurringScheduleData { + task_id?: string, + id?: string, + schedule_type?: string +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 9c5da9bf..d155490c 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask { timer_start_time?: number; recurring?: boolean; task_level?: number; + schedule_id?: string | null; } export interface ITaskTeamMember extends ITeamMember { From d333104f438a956188cbc3041bc1bec03202ec96 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Fri, 16 May 2025 07:21:57 +0530 Subject: [PATCH 2/7] feat(i18n): add recurring task translation keys Add new localization entries for the recurring task feature in English, Spanish, and Portuguese. This update includes the addition of the "recurring" key to the task drawer JSON files, enhancing support for recurring task configurations across multiple languages. --- .../public/locales/en/task-drawer/task-drawer.json | 3 ++- .../public/locales/es/task-drawer/task-drawer.json | 3 ++- .../public/locales/pt/task-drawer/task-drawer.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index e013b4f2..06575ee1 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Task Weight", "taskWeightTooltip": "Set the weight of this subtask (percentage)", "taskWeightRequired": "Please enter a task weight", - "taskWeightRange": "Weight must be between 0 and 100" + "taskWeightRange": "Weight must be between 0 and 100", + "recurring": "Recurring" }, "labels": { "labelInputPlaceholder": "Search or create", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 8b3ef220..c3980da8 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso de la Tarea", "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", "taskWeightRequired": "Por favor, introduce un peso para la tarea", - "taskWeightRange": "El peso debe estar entre 0 y 100" + "taskWeightRange": "El peso debe estar entre 0 y 100", + "recurring": "Recurrente" }, "labels": { "labelInputPlaceholder": "Buscar o crear", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 7a3933f2..6288af92 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso da Tarefa", "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", "taskWeightRequired": "Por favor, insira um peso para a tarefa", - "taskWeightRange": "O peso deve estar entre 0 e 100" + "taskWeightRange": "O peso deve estar entre 0 e 100", + "recurring": "Recorrente" }, "labels": { "labelInputPlaceholder": "Pesquisar ou criar", From ebd0f6676821473f613b4810c0f27d74ab43d281 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 12:37:02 +0530 Subject: [PATCH 3/7] feat(task-drawer): integrate socket handling for recurring task updates Enhance the TaskDrawerRecurringConfig component to include socket communication for handling recurring task changes. This update introduces the use of Redux for managing state updates related to recurring schedules, ensuring real-time synchronization of task configurations. Additionally, the code has been refactored for improved readability and maintainability. --- .../task-drawer-recurring-config.tsx | 480 ++++++++++-------- .../src/features/tasks/tasks.slice.ts | 11 + 2 files changed, 275 insertions(+), 216 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 56daee49..1e5af1d8 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -1,245 +1,293 @@ import React, { useState, useMemo, useEffect } from 'react'; -import { Form, Switch, Button, Popover, Select, Checkbox, Radio, InputNumber, Skeleton, Row, Col } from 'antd'; +import { + Form, + Switch, + Button, + Popover, + Select, + Checkbox, + Radio, + InputNumber, + Skeleton, + Row, + Col, +} from 'antd'; import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { updateRecurringChange } from '@/features/tasks/tasks.slice'; -// Dummy enums and types for demonstration; replace with actual imports/types const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', + Weekly: 'weekly', + EveryXDays: 'every_x_days', + EveryXWeeks: 'every_x_weeks', + EveryXMonths: 'every_x_months', + Monthly: 'monthly', }; const repeatOptions = [ - { label: 'Weekly', value: ITaskRecurring.Weekly }, - { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, - { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, - { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, - { label: 'Monthly', value: ITaskRecurring.Monthly }, + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { label: 'Mon', value: 'mon' }, + { label: 'Tue', value: 'tue' }, + { label: 'Wed', value: 'wed' }, + { label: 'Thu', value: 'thu' }, + { label: 'Fri', value: 'fri' }, + { label: 'Sat', value: 'sat' }, + { label: 'Sun', value: 'sun' }, ]; const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth', value: 'fourth' }, + { label: 'Last', value: 'last' }, ]; const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); -const TaskDrawerRecurringConfig = ({ task }: {task: ITaskViewModel}) => { - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); +const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); - const [recurring, setRecurring] = useState(false); - const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); - const [selectedDays, setSelectedDays] = useState([]); - const [monthlyOption, setMonthlyOption] = useState('date'); - const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); - const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); - const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); - const [intervalDays, setIntervalDays] = useState(1); - const [intervalWeeks, setIntervalWeeks] = useState(1); - const [intervalMonths, setIntervalMonths] = useState(1); - const [loadingData, setLoadingData] = useState(false); - const [updatingData, setUpdatingData] = useState(false); + const [recurring, setRecurring] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [selectedDays, setSelectedDays] = useState([]); + const [monthlyOption, setMonthlyOption] = useState('date'); + const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); + const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); + const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); + const [intervalDays, setIntervalDays] = useState(1); + const [intervalWeeks, setIntervalWeeks] = useState(1); + const [intervalMonths, setIntervalMonths] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const [updatingData, setUpdatingData] = useState(false); + + const handleChange = (checked: boolean) => { + if (!task.id) return; + + socket?.emit(SocketEvents.TASK_RECURRING_CHANGE.toString(), { + task_id: task.id, + schedule_id: task.schedule_id, + }); + + socket?.once( + SocketEvents.TASK_RECURRING_CHANGE.toString(), + (schedule: ITaskRecurringScheduleData) => { + if (schedule.id && schedule.schedule_type) { + const selected = repeatOptions.find(e => e.value == schedule.schedule_type); + if (selected) setRepeatOption(selected); + } + dispatch(updateRecurringChange(schedule)); - const handleChange = (checked: boolean) => { setRecurring(checked); if (!checked) setShowConfig(false); - }; - - const configVisibleChange = (visible: boolean) => { - setShowConfig(visible); - }; - - const isMonthlySelected = useMemo(() => repeatOption.value === ITaskRecurring.Monthly, [repeatOption]); - - const handleDayCheckboxChange = (checkedValues: string[]) => { - setSelectedDays(checkedValues as unknown as string[]); - }; - - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, - }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); - }; - - const getScheduleData = () => { - - }; - - const handleResponse = (response: ITaskRecurringScheduleData) => { - if (!task || !response.task_id) return; - } - - useEffect(() => { - if (task) setRecurring(!!task.schedule_id); - if (recurring) void getScheduleData(); - socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse) - }, []) - - return ( -
- -
- -   - {recurring && ( - -
- - ({ label: date.toString(), value: date }))} - style={{ width: 120 }} - /> - - )} - {monthlyOption === 'day' && ( - <> - - - - - )} - - )} - - {repeatOption.value === ITaskRecurring.EveryXDays && ( - - value && setIntervalDays(value)} /> - - )} - {repeatOption.value === ITaskRecurring.EveryXWeeks && ( - - value && setIntervalWeeks(value)} /> - - )} - {repeatOption.value === ITaskRecurring.EveryXMonths && ( - - value && setIntervalMonths(value)} /> - - )} - - - -
- - } - overlayStyle={{ width: 510 }} - open={showConfig} - onOpenChange={configVisibleChange} - trigger="click" - > - -
- )} -
-
-
+ } ); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo( + () => repeatOption.value === ITaskRecurring.Monthly, + [repeatOption] + ); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => {}; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + }; + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); + }, []); + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ + label: date.toString(), + value: date, + }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} + /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); }; -export default TaskDrawerRecurringConfig; \ No newline at end of file +export default TaskDrawerRecurringConfig; diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index cd443dbf..49c85e28 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon import { produce } from 'immer'; import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service'; import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; export enum IGroupBy { STATUS = 'status', @@ -1006,6 +1007,15 @@ const taskSlice = createSlice({ column.pinned = isVisible; } }, + + updateRecurringChange: (state, action: PayloadAction) => { + const {id, schedule_type, task_id} = action.payload; + const taskInfo = findTaskInGroups(state.taskGroups, task_id as string); + if (!taskInfo) return; + + const { task } = taskInfo; + task.schedule_id = id; + } }, extraReducers: builder => { @@ -1165,6 +1175,7 @@ export const { updateSubTasks, updateCustomColumnValue, updateCustomColumnPinned, + updateRecurringChange } = taskSlice.actions; export default taskSlice.reducer; From 2e985bd05161bd337a7904820f3ad17706c3edd8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:32:45 +0530 Subject: [PATCH 4/7] feat(recurring-tasks): implement recurring task scheduling and API integration --- worklenz-backend/src/cron_jobs/index.ts | 4 +- .../src/cron_jobs/recurring-tasks.ts | 2 +- .../api/tasks/task-recurring.api.service.ts | 16 ++ .../task-drawer-recurring-config.tsx | 164 +++++++++++++----- .../features/task-drawer/task-drawer.slice.ts | 10 ++ .../types/tasks/task-recurring-schedule.ts | 48 +++-- 6 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 worklenz-frontend/src/api/tasks/task-recurring.api.service.ts diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index f13ec2e8..20bd4f62 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -1,11 +1,11 @@ import {startDailyDigestJob} from "./daily-digest-job"; import {startNotificationsJob} from "./notifications-job"; import {startProjectDigestJob} from "./project-digest-job"; -import { startRecurringTasksJob } from "./recurring-tasks"; +import {startRecurringTasksJob} from "./recurring-tasks"; export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - // startRecurringTasksJob(); + startRecurringTasksJob(); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index a9ae7847..16854c7e 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,7 +7,7 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; +const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day diff --git a/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts new file mode 100644 index 00000000..6e19d7cb --- /dev/null +++ b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/shared/constants"; +import { IServerResponse } from "@/types/common.types"; +import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule"; +import apiClient from "../api-client"; + +const rootUrl = `${API_BASE_URL}/task-recurring`; + +export const taskRecurringApiService = { + getTaskRecurringData: async (schedule_id: string): Promise> => { + const response = await apiClient.get(`${rootUrl}/${schedule_id}`); + return response.data; + }, + updateTaskRecurringData: async (schedule_id: string, body: any): Promise> => { + return apiClient.put(`${rootUrl}/${schedule_id}`, body); + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 1e5af1d8..608fc321 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -15,21 +15,17 @@ import { import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; -import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateRecurringChange } from '@/features/tasks/tasks.slice'; +import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'; +import logger from '@/utils/errorLogger'; +import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', -}; - -const repeatOptions = [ +const repeatOptions: IRepeatOption[] = [ + { label: 'Daily', value: ITaskRecurring.Daily }, { label: 'Weekly', value: ITaskRecurring.Weekly }, { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, @@ -38,22 +34,22 @@ const repeatOptions = [ ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { label: 'Sunday', value: 0, checked: false }, + { label: 'Monday', value: 1, checked: false }, + { label: 'Tuesday', value: 2, checked: false }, + { label: 'Wednesday', value: 3, checked: false }, + { label: 'Thursday', value: 4, checked: false }, + { label: 'Friday', value: 5, checked: false }, + { label: 'Saturday', value: 6, checked: false } ]; -const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, + { label: 'Third', value: 3 }, + { label: 'Fourth', value: 4 }, + { label: 'Last', value: 5 } ]; const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); @@ -64,7 +60,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [repeatOption, setRepeatOption] = useState({}); const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); @@ -75,6 +71,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [intervalMonths, setIntervalMonths] = useState(1); const [loadingData, setLoadingData] = useState(false); const [updatingData, setUpdatingData] = useState(false); + const [scheduleData, setScheduleData] = useState({}); const handleChange = (checked: boolean) => { if (!task.id) return; @@ -92,6 +89,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (selected) setRepeatOption(selected); } dispatch(updateRecurringChange(schedule)); + dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })); setRecurring(checked); if (!checked) setShowConfig(false); @@ -112,35 +110,119 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { setSelectedDays(checkedValues as unknown as string[]); }; - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, + const getSelectedDays = () => { + return daysOfWeek + .filter(day => day.checked) // Get only the checked days + .map(day => day.value); // Extract their numeric values + } + + const getUpdateBody = () => { + if (!task.id || !task.schedule_id || !repeatOption.value) return; + + const body: ITaskRecurringSchedule = { + id: task.id, + schedule_type: repeatOption.value }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); + + switch (repeatOption.value) { + case ITaskRecurring.Weekly: + body.days_of_week = getSelectedDays(); + break; + + case ITaskRecurring.Monthly: + if (monthlyOption === 'date') { + body.date_of_month = selectedMonthlyDate; + setSelectedMonthlyDate(0); + setSelectedMonthlyDay(0); + } else { + body.week_of_month = selectedMonthlyWeek; + body.day_of_month = selectedMonthlyDay; + setSelectedMonthlyDate(0); + } + break; + + case ITaskRecurring.EveryXDays: + body.interval_days = intervalDays; + break; + + case ITaskRecurring.EveryXWeeks: + body.interval_weeks = intervalWeeks; + break; + + case ITaskRecurring.EveryXMonths: + body.interval_months = intervalMonths; + break; + } + return body; + } + + const handleSave = async () => { + if (!task.id || !task.schedule_id) return; + + try { + setUpdatingData(true); + const body = getUpdateBody(); + + const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); + if (res.done) { + setShowConfig(false); + } + } catch (e) { + logger.error("handleSave", e); + } finally { + setUpdatingData(false); + } }; - const getScheduleData = () => {}; + const updateDaysOfWeek = () => { + for (let i = 0; i < daysOfWeek.length; i++) { + daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false; + } + }; + + const getScheduleData = async () => { + if (!task.schedule_id) return; + setLoadingData(true); + try { + const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id); + if (res.done) { + setScheduleData(res.body); + if (!res.body) { + setRepeatOption(repeatOptions[0]); + } else { + const selected = repeatOptions.find(e => e.value == res.body.schedule_type); + if (selected) { + setRepeatOption(selected); + setSelectedMonthlyDate(scheduleData.date_of_month || 1); + setSelectedMonthlyDay(scheduleData.day_of_month || 0); + setSelectedMonthlyWeek(scheduleData.week_of_month || 0); + setIntervalDays(scheduleData.interval_days || 1); + setIntervalWeeks(scheduleData.interval_weeks || 1); + setIntervalMonths(scheduleData.interval_months || 1); + setMonthlyOption(selectedMonthlyDate ? 'date' : 'day'); + updateDaysOfWeek(); + } + } + }; + } catch (e) { + logger.error("getScheduleData", e); + } + finally { + setLoadingData(false); + } + } const handleResponse = (response: ITaskRecurringScheduleData) => { if (!task || !response.task_id) return; }; useEffect(() => { + if (!task) return; + if (task) setRecurring(!!task.schedule_id); if (recurring) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, []); + }, [task]); return (
diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 9654a2d0..74ba350c 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({ }>) => { state.timeLogEditing = action.payload; }, + setTaskRecurringSchedule: (state, action: PayloadAction<{ + schedule_id: string; + task_id: string; + }>) => { + const { schedule_id, task_id } = action.payload; + if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) { + state.taskFormViewModel.task.schedule_id = schedule_id; + } + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -133,5 +142,6 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, + setTaskRecurringSchedule } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts index 8fc708d5..190b6e7f 100644 --- a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -1,19 +1,37 @@ +export enum ITaskRecurring { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', + EveryXDays = 'every_x_days', + EveryXWeeks = 'every_x_weeks', + EveryXMonths = 'every_x_months' +} + export interface ITaskRecurringSchedule { - type: 'daily' | 'weekly' | 'monthly' | 'interval'; - dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) - dayOfMonth?: number; // 1 - 31 (for monthly tasks) - weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) - hour: number; // Time of the day in 24-hour format - minute: number; // Minute of the hour - interval?: { - days?: number; // Interval in days (for every x days) - weeks?: number; // Interval in weeks (for every x weeks) - months?: number; // Interval in months (for every x months) - }; + created_at?: string; + day_of_month?: number | null; + date_of_month?: number | null; + days_of_week?: number[] | null; + id?: string; // UUID v4 + interval_days?: number | null; + interval_months?: number | null; + interval_weeks?: number | null; + schedule_type?: ITaskRecurring; + week_of_month?: number | null; +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string } export interface ITaskRecurringScheduleData { - task_id?: string, - id?: string, - schedule_type?: string -} \ No newline at end of file + task_id?: string, + id?: string, + schedule_type?: string +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +} From 49bdd00dacbb258c2692f87894656d6f8879a62c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:45:47 +0530 Subject: [PATCH 5/7] fix(todo-list): update empty list image source to use relative path --- worklenz-frontend/src/pages/home/todo-list/todo-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index 9fe5c59c..a27d2ac0 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( From f3a7fd8be5659f378e60a2a8d4494ac6cda4ae30 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:15:40 +0530 Subject: [PATCH 6/7] refactor(project-view): optimize component with useMemo and useCallback for performance improvements - Introduced useMemo and useCallback to memoize tab menu items and callback functions, enhancing performance. - Added resetProjectData function to clean up project state on component unmount. - Refactored the component to use React.memo for preventing unnecessary re-renders. --- .../projects/projectView/project-view.tsx | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 91c1d636..d1ff8b9d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; @@ -43,6 +43,14 @@ const ProjectView = () => { const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); const [taskid, setTaskId] = useState(searchParams.get('task') || ''); + const resetProjectData = useCallback(() => { + dispatch(setProjectId(null)); + dispatch(resetStatuses()); + dispatch(deselectAll()); + dispatch(resetTaskListData()); + dispatch(resetBoardData()); + }, [dispatch]); + useEffect(() => { if (projectId) { dispatch(setProjectId(projectId)); @@ -59,9 +67,13 @@ const ProjectView = () => { dispatch(setSelectedTaskId(taskid || '')); dispatch(setShowTaskDrawer(true)); } - }, [dispatch, navigate, projectId, taskid]); - const pinToDefaultTab = async (itemKey: string) => { + return () => { + resetProjectData(); + }; + }, [dispatch, navigate, projectId, taskid, resetProjectData]); + + const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; @@ -88,9 +100,9 @@ const ProjectView = () => { }).toString(), }); } - }; + }, [projectId, activeTab, navigate]); - const handleTabChange = (key: string) => { + const handleTabChange = useCallback((key: string) => { setActiveTab(key); dispatch(setProjectView(key === 'board' ? 'kanban' : 'list')); navigate({ @@ -100,9 +112,9 @@ const ProjectView = () => { pinned_tab: pinnedTab, }).toString(), }); - }; + }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = tabItems.map(item => ({ + const tabMenuItems = useMemo(() => tabItems.map(item => ({ key: item.key, label: ( @@ -144,21 +156,17 @@ const ProjectView = () => { ), children: item.element, - })); + })), [pinnedTab, pinToDefaultTab]); - const resetProjectData = () => { - dispatch(setProjectId(null)); - dispatch(resetStatuses()); - dispatch(deselectAll()); - dispatch(resetTaskListData()); - dispatch(resetBoardData()); - }; - - useEffect(() => { - return () => { - resetProjectData(); - }; - }, []); + const portalElements = useMemo(() => ( + <> + {createPortal(, document.body, 'project-member-drawer')} + {createPortal(, document.body, 'phase-drawer')} + {createPortal(, document.body, 'status-drawer')} + {createPortal(, document.body, 'task-drawer')} + {createPortal(, document.body, 'delete-status-drawer')} + + ), []); return (
@@ -170,33 +178,11 @@ const ProjectView = () => { items={tabMenuItems} tabBarStyle={{ paddingInline: 0 }} destroyInactiveTabPane={true} - // tabBarExtraContent={ - //
- // - // - // - // - // - // - // - // - //
- // } /> - {createPortal(, document.body, 'project-member-drawer')} - {createPortal(, document.body, 'phase-drawer')} - {createPortal(, document.body, 'status-drawer')} - {createPortal(, document.body, 'task-drawer')} - {createPortal(, document.body, 'delete-status-drawer')} + {portalElements}
); }; -export default ProjectView; +export default React.memo(ProjectView); From f9858fbd4bcbe839aa257ba9232be7f0e1b02cce Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:58:20 +0530 Subject: [PATCH 7/7] refactor(task-list): enhance performance with useMemo and useCallback - Introduced useMemo to optimize loading state and empty state calculations. - Added useMemo for socket event handler functions to prevent unnecessary re-renders. - Refactored data fetching logic to improve initial data load handling. - Improved drag-and-drop functionality with memoized handlers for better performance. --- .../taskList/project-view-task-list.tsx | 84 ++-- .../task-group-wrapper/task-group-wrapper.tsx | 395 +++++++++--------- 2 files changed, 250 insertions(+), 229 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index fcd4931a..410644fb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import Flex from 'antd/es/flex'; import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; @@ -17,8 +17,8 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - // Add local loading state to immediately show skeleton const [isLoading, setIsLoading] = useState(true); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( @@ -30,47 +30,73 @@ const ProjectViewTaskList = () => { const { loadingPhases } = useAppSelector(state => state.phaseReducer); const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Memoize the loading state calculation - ignoring task list filter loading + const isLoadingState = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories, + [loadingGroups, loadingPhases, loadingStatusCategories] + ); + + // Memoize the empty state check + const isEmptyState = useMemo(() => + taskGroups && taskGroups.length === 0 && !isLoadingState, + [taskGroups, isLoadingState] + ); + + // Handle view type changes useEffect(() => { - // Set default view to list if projectView is not list or board if (projectView !== 'list' && projectView !== 'board') { - searchParams.set('tab', 'tasks-list'); - searchParams.set('pinned_tab', 'tasks-list'); - setSearchParams(searchParams); + const newParams = new URLSearchParams(searchParams); + newParams.set('tab', 'tasks-list'); + newParams.set('pinned_tab', 'tasks-list'); + setSearchParams(newParams); } - }, [projectView, searchParams, setSearchParams]); + }, [projectView, setSearchParams]); + // Update loading state useEffect(() => { - // Set loading state based on all loading conditions - setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories); - }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]); + setIsLoading(isLoadingState); + }, [isLoadingState]); + // Fetch initial data only once useEffect(() => { - const loadData = async () => { - if (projectId && groupBy) { - const promises = []; - - if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId))); - if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId))); - if (!loadingGroups && projectView === 'list') { - promises.push(dispatch(fetchTaskGroups(projectId))); - } - if (!statusCategories.length) { - promises.push(dispatch(fetchStatusesCategories())); - } - - // Wait for all data to load - await Promise.all(promises); + const fetchInitialData = async () => { + if (!projectId || !groupBy || initialLoadComplete) return; + + try { + await Promise.all([ + dispatch(fetchTaskListColumns(projectId)), + dispatch(fetchPhasesByProjectId(projectId)), + dispatch(fetchStatusesCategories()) + ]); + setInitialLoadComplete(true); + } catch (error) { + console.error('Error fetching initial data:', error); } }; - - loadData(); - }, [dispatch, projectId, groupBy, fields, search, archived]); + + fetchInitialData(); + }, [projectId, groupBy, dispatch, initialLoadComplete]); + + // Fetch task groups + useEffect(() => { + const fetchTasks = async () => { + if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; + + try { + await dispatch(fetchTaskGroups(projectId)); + } catch (error) { + console.error('Error fetching task groups:', error); + } + }; + + fetchTasks(); + }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); return ( - {(taskGroups && taskGroups.length === 0 && !isLoading) ? ( + {isEmptyState ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index f619f20a..6a0e9374 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useSocket } from '@/socket/socketContext'; import { useAuthService } from '@/hooks/useAuth'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import Flex from 'antd/es/flex'; import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; @@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees); const { projectId } = useAppSelector(state => state.projectReducer); - const sensors = useSensors( - useSensor(PointerSensor, { + // Move useSensors to top level and memoize its configuration + const sensorConfig = useMemo( + () => ({ activationConstraint: { distance: 8 }, - }) + }), + [] ); + const pointerSensor = useSensor(PointerSensor, sensorConfig); + const sensors = useSensors(pointerSensor); + useEffect(() => { setGroups(taskGroups); }, [taskGroups]); + // Memoize resetTaskRowStyles to prevent unnecessary re-renders const resetTaskRowStyles = useCallback(() => { document.querySelectorAll('.task-row').forEach(row => { row.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; @@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); }, []); - // Socket handler for assignee updates - useEffect(() => { - if (!socket) return; - - const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => { + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { if (!data) return; - const updatedAssignees = data.assignees.map(assignee => ({ + const updatedAssignees = data.assignees?.map(assignee => ({ ...assignee, selected: true, - })); + })) || []; - // Find the group that contains the task or its subtasks - const groupId = groups.find(group => - group.tasks.some( + const groupId = groups?.find(group => + group.tasks?.some( task => task.id === data.id || (task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id)) @@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }) ); - dispatch(setTaskAssignee(data)); + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); if (currentSession?.team_id && !loadingAssignees) { dispatch(fetchTaskAssignees(currentSession.team_id)); } } - }; + }, + [groups, dispatch, currentSession?.team_id, loadingAssignees] + ); - socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - return () => { - socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - }; - }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]); - - // Socket handler for label updates - useEffect(() => { - if (!socket) return; - - const handleLabelsChange = async (labels: ILabelsChangeResponse) => { + // Memoize socket event handlers + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + await Promise.all([ dispatch(updateTaskLabel(labels)), dispatch(setTaskLabels(labels)), dispatch(fetchLabels()), projectId && dispatch(fetchLabelsByProject(projectId)), ]); - }; + }, + [dispatch, projectId] + ); - socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); + // Memoize socket event handlers + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); - }; - }, [socket, dispatch, projectId]); - - // Socket handler for status updates - useEffect(() => { - if (!socket) return; - - const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => { if (response.completed_deps === false) { alertService.error( 'Task is not completed', @@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } dispatch(updateTaskStatus(response)); - // dispatch(setTaskStatus(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - const handleTaskProgress = (data: { + // Memoize socket event handlers + const handleTaskProgress = useCallback( + (data: { id: string; status: string; complete_ratio: number; @@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { total_tasks_count: number; parent_task: string; }) => { + if (!data) return; + dispatch( updateTaskProgress({ taskId: data.parent_task || data.id, @@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { completedCount: data.completed_count, }) ); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + // Memoize socket event handlers + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - }; - }, [socket, dispatch]); - - // Socket handler for priority updates - useEffect(() => { - if (!socket) return; - - const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => { dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - - return () => { - socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - }; - }, [socket, dispatch]); - - // Socket handler for due date updates - useEffect(() => { - if (!socket) return; - - const handleEndDateChange = (task: { + // Memoize socket event handlers + const handleEndDateChange = useCallback( + (task: { id: string; parent_task: string | null; end_date: string; }) => { - dispatch(updateTaskEndDate({ task })); - dispatch(setTaskEndDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task name updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; - const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => { dispatch(updateTaskName(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); + // Memoize socket event handlers + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; - return () => { - socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); - }; - }, [socket, dispatch]); - - // Socket handler for phase updates - useEffect(() => { - if (!socket) return; - - const handlePhaseChange = (data: ITaskPhaseChangeResponse) => { dispatch(updateTaskPhase(data)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - - return () => { - socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - }; - }, [socket, dispatch]); - - // Socket handler for start date updates - useEffect(() => { - if (!socket) return; - - const handleStartDateChange = (task: { + // Memoize socket event handlers + const handleStartDateChange = useCallback( + (task: { id: string; parent_task: string | null; start_date: string; }) => { - dispatch(updateTaskStartDate({ task })); - dispatch(setStartDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task subscribers updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; - const handleTaskSubscribersChange = (data: InlineMember[]) => { dispatch(setTaskSubscribers(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - - return () => { - socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - }; - }, [socket, dispatch]); - - // Socket handler for task estimation updates - useEffect(() => { - if (!socket) return; - - const handleEstimationChange = (task: { + // Memoize socket event handlers + const handleEstimationChange = useCallback( + (task: { id: string; parent_task: string | null; estimation: number; }) => { - dispatch(updateTaskEstimation({ task })); - }; + if (!task) return; - socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); - // Socket handler for task description updates - useEffect(() => { - if (!socket) return; - - const handleTaskDescriptionChange = (data: { + // Memoize socket event handlers + const handleTaskDescriptionChange = useCallback( + (data: { id: string; parent_task: string; description: string; }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - - return () => { - socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - }; - }, [socket, dispatch]); - - // Socket handler for new task creation - useEffect(() => { - if (!socket) return; - - const handleNewTaskReceived = (data: IProjectTask) => { + // Memoize socket event handlers + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { if (!data) return; if (data.parent_task_id) { dispatch(updateSubTasks(data)); } - }; + }, + [dispatch] + ); - socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - - return () => { - socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - }; - }, [socket, dispatch]); - - // Socket handler for task progress updates - useEffect(() => { - if (!socket) return; - - const handleTaskProgressUpdated = (data: { + // Memoize socket event handlers + const handleTaskProgressUpdated = useCallback( + (data: { task_id: string; progress_value?: number; weight?: number; }) => { + if (!data || !taskGroups) return; + if (data.progress_value !== undefined) { - // Find the task in the task groups and update its progress for (const group of taskGroups) { - const task = group.tasks.find(task => task.id === data.task_id); + const task = group.tasks?.find(task => task.id === data.task_id); if (task) { dispatch( updateTaskProgress({ @@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } } } + }, + [dispatch, taskGroups] + ); + + // Set up socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = { + [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate, + [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange, + [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange, + [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange, + [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress, + [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange, + [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange, + [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange, + [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange, + [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange, + [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange, + [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange, + [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange, + [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived, + [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated, }; - socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + // Register all event handlers + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.on(event, handler); + } + }); + // Cleanup function return () => { - socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.off(event, handler); + } + }); }; - }, [socket, dispatch, taskGroups]); + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); + // Memoize drag handlers const handleDragStart = useCallback(({ active }: DragStartEvent) => { setActiveId(active.id as string); - // Add smooth transition to the dragged item const draggedElement = document.querySelector(`[data-id="${active.id}"]`); if (draggedElement) { (draggedElement as HTMLElement).style.transition = 'transform 0.2s ease'; } }, []); + // Memoize drag handlers const handleDragEnd = useCallback( async ({ active, over }: DragEndEvent) => { setActiveId(null); @@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); if (fromIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Check if task dependencies allow the move if (activeGroupId !== overGroupId) { const canContinue = await checkTaskDependencyStatus(task.id, overGroupId); if (!canContinue) { @@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { return; } - // Update task properties based on target group switch (groupBy) { case IGroupBy.STATUS: task.status = overGroupId; @@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { task.priority_color_dark = targetGroup.color_code_dark; break; case IGroupBy.PHASE: - // Check if ALPHA_CHANNEL is already added const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL) - ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL - : targetGroup.color_code; // Use as is if not present + ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) + : targetGroup.color_code; task.phase_id = overGroupId; - task.phase_color = baseColor; // Set the cleaned color + task.phase_color = baseColor; break; } } const isTargetGroupEmpty = targetGroup.tasks.length === 0; - - // Calculate toIndex - for empty groups, always add at index 0 const toIndex = isTargetGroupEmpty ? 0 : overTaskId ? targetGroup.tasks.findIndex(t => t.id === overTaskId) : targetGroup.tasks.length; - // Calculate toPos similar to Angular implementation const toPos = isTargetGroupEmpty ? -1 : targetGroup.tasks[toIndex]?.sort_order || targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || -1; - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; @@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } - // Emit socket event socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { from_group: sourceGroup.id, to_group: targetGroup.id, group_by: groupBy, - task: sourceGroup.tasks[fromIndex], // Send original task to maintain references + task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, }); - // Reset styles setTimeout(resetTaskRowStyles, 0); - trackMixpanelEvent(evt_project_task_list_drag_and_move); }, [ @@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { ] ); + // Memoize drag handlers const handleDragOver = useCallback( ({ active, over }: DragEndEvent) => { if (!over) return; @@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { if (fromIndex === -1 || toIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(toIndex, 0, task); dispatch({ @@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { // Handle animation cleanup after drag ends useIsomorphicLayoutEffect(() => { if (activeId === null) { - // Final cleanup after React updates DOM const timeoutId = setTimeout(resetTaskRowStyles, 50); return () => clearTimeout(timeoutId); }