From fd9bc10e161632285223a023d5c67f519cc23d92 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Mon, 29 Jun 2026 10:58:13 +0300 Subject: [PATCH 01/39] 59876 refactor(template): migrate template edit form to Formik --- .../TemplateEdit/TaskForm/TaskForm.tsx | 253 +++--------------- .../TemplateEdit/TaskForm/TaskFormHeader.tsx | 58 ++++ .../TaskForm/TaskFormSections.tsx | 97 +++++++ .../TaskForm/useTaskFormParts.tsx | 203 ++++++++++++++ .../WorkflowLogTaskComplete.tsx | 8 +- .../WorkflowLogTaskComplete.test.tsx | 38 +++ 6 files changed, 438 insertions(+), 219 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx create mode 100644 frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx index 93e16629a..2e6ca0d60 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -1,33 +1,18 @@ import * as React from 'react'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; -import { useIntl } from 'react-intl'; +import { useMemo, useRef } from 'react'; +import { FormikProvider, useFormik } from 'formik'; -import { TaskDescriptionEditor } from './TaskDescriptionEditor'; -import { scrollToElement } from '../../../utils/helpers'; import { TUserListItem } from '../../../types/user'; import { IKickoff, ITemplateTask } from '../../../types/template'; -import { TTaskVariable, TTaskFormPart, ETaskFormParts } from '../types'; -import { OutputFormIntl } from '../OutputForm'; -import { ShowMore } from '../../UI/ShowMore'; -import { TaskPerformers } from './TaskPerformers'; -import { CheckIfConditions, ICondition, removeDeletedTasks, StartAfterCondition } from './Conditions'; -import { InputWithVariables } from '../InputWithVariables'; +import { TTaskVariable, TTaskFormPart } from '../types'; import { TPatchTaskPayload } from '../../../redux/actions'; -import { ReturnTo } from './ReturnTo'; -import { DueDate } from './DueDate'; -import { getSingleLineVariables, getSystemVariables } from './utils/getTaskVariables'; +import { getSystemVariables } from './utils/getTaskVariables'; +import { TaskFormHeader } from './TaskFormHeader'; +import { TaskFormSections } from './TaskFormSections'; import styles from '../TemplateEdit.css'; -import { EStartingType } from './Conditions/utils/getDropdownOperators'; -import { TaskItemUsers } from '../TaskItem/TaskItemUsers'; -import { TaskRenderDueIn } from '../TaskRenderDueInInfo'; -import { TaskRenderConditionsInfo } from '../TaskRenderConditionsInfo'; -import { TaskRenderExtraFieldsInfo } from '../TaskRenderExtraFieldsInfo'; -import { TaskRenderReturnInfo } from '../TaskRenderReturnInfo'; -import { StepName } from '../../StepName'; - export interface ITaskFormProps { listVariables: TTaskVariable[]; templateVariables: TTaskVariable[]; @@ -57,220 +42,58 @@ export function TaskForm({ templateId, }: ITaskFormProps & { templateId: number | undefined }) { if (!task) return null; - const { formatMessage } = useIntl(); + const wrapperRef = useRef(null); - const taskName = task.name || ''; + const formik = useFormik({ + initialValues: task, + enableReinitialize: true, + onSubmit: () => undefined, + }); + const currentTask = formik.values; const listSystemVariables = useMemo(() => [ ...getSystemVariables(), ...listVariables, ], [listVariables]); - const taskFormPartsRefs = { - [ETaskFormParts.AssignPerformers]: useRef(null), - [ETaskFormParts.DueIn]: useRef(null), - [ETaskFormParts.Fields]: useRef(null), - [ETaskFormParts.StartsAfter]: useRef(null), - [ETaskFormParts.CheckIf]: useRef(null), - [ETaskFormParts.ReturnTo]: useRef(null), - }; - const startingOrder: TTaskVariable[] = [ - { - title: formatMessage({ id: 'templates.conditions.starting-order.kick-off' }), - apiName: `kick-off`, - type: EStartingType.Kickoff, - }, - ...tasks - .filter((localTask) => task.apiName !== localTask.apiName) - .map((currentTask) => { - return { - apiName: currentTask.apiName, - title: currentTask.name, - type: EStartingType.Task, - richSubtitle: , - }; - }), - ]; - - const onEdit = useCallback( - (conditions: ICondition[]) => { - patchTask({ taskUUID: task.uuid, changedFields: { conditions } }); - }, - [patchTask, task.uuid], - ); - - useEffect(() => { - removeDeletedTasks(startingOrder, task.conditions, onEdit); - }, [startingOrder, task.conditions, onEdit]); - - useLayoutEffect(() => { - const scrollTo = (scrollTarget && taskFormPartsRefs[scrollTarget]?.current) || wrapperRef.current; - - if (scrollTo) scrollToElement(scrollTo); - }, []); const setCurrentTask = (changedFields: Partial) => { - patchTask({ taskUUID: task.uuid, changedFields }); + Object.entries(changedFields).forEach(([field, value]) => formik.setFieldValue(field, value, false)); + patchTask({ taskUUID: currentTask.uuid, changedFields }); }; const handleTaskFieldChange = (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => { setCurrentTask({ [field]: value }); }; - const createWidget = useCallback( - (Component, props) => { - return (toggle: () => void) => ( - - ); - }, - [task], - ); - - const taskFormParts = [ - { - formPartId: ETaskFormParts.AssignPerformers, - title: 'tasks.task-assign-help', - component: ( - - ), - widget: createWidget(TaskItemUsers, { task }), - }, - { - formPartId: ETaskFormParts.DueIn, - title: 'tasks.task-due-date', - component: ( -
- -
- ), - widget: createWidget(TaskRenderDueIn, { task, isInTaskForm: true }), - }, - { - formPartId: ETaskFormParts.Fields, - title: 'tasks.task-outputs-create-help', - component: ( - - ), - widget: createWidget(TaskRenderExtraFieldsInfo, { task }), - }, - { - formPartId: ETaskFormParts.StartsAfter, - title: 'templates.starts-after.title', - component: ( - - ), - widget: createWidget(TaskRenderConditionsInfo, { - task, - isInTaskForm: true, - isStartTask: true, - }), - }, - { - formPartId: ETaskFormParts.CheckIf, - title: 'templates.conditions.check-if-title', - component: ( - - ), - widget: createWidget(TaskRenderConditionsInfo, { task, isInTaskForm: true, isStartTask: false }), - }, - { - formPartId: ETaskFormParts.ReturnTo, - title: 'templates.return-to.title', - component: ( - - ), - widget: createWidget(TaskRenderReturnInfo, { task }), - }, - ]; - return ( -
-
-
- +
+
+ { - handleTaskFieldChange('name')(value); - - return Promise.resolve(value); - }} - className={styles['task-name-field']} - toolipText={formatMessage({ id: 'tasks.task-description-button-tooltip' })} /> - { - handleTaskFieldChange('description')(value); - return Promise.resolve(value); - }} - handleChangeChecklists={handleTaskFieldChange('checklists')} - value={task.description || ''} - listVariables={listSystemVariables} - templateVariables={templateVariables} +
- - {taskFormParts.map(({ title, component, formPartId, widget }) => ( - - {component} - - ))}
-
+ ); } diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx new file mode 100644 index 000000000..48b7bd97d --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; + +import { ITemplateTask } from '../../../types/template'; +import { TTaskVariable } from '../types'; +import { InputWithVariables } from '../InputWithVariables'; +import { TaskDescriptionEditor } from './TaskDescriptionEditor'; +import { getSingleLineVariables } from './utils/getTaskVariables'; + +import styles from '../TemplateEdit.css'; + +interface ITaskFormHeaderProps { + accountId: number; + currentTask: ITemplateTask; + listSystemVariables: TTaskVariable[]; + templateVariables: TTaskVariable[]; + handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; +} + +export function TaskFormHeader({ + accountId, + currentTask, + handleTaskFieldChange, + listSystemVariables, + templateVariables, +}: ITaskFormHeaderProps) { + const { formatMessage } = useIntl(); + + return ( +
+ { + handleTaskFieldChange('name')(value); + + return Promise.resolve(value); + }} + className={styles['task-name-field']} + toolipText={formatMessage({ id: 'tasks.task-description-button-tooltip' })} + /> + { + handleTaskFieldChange('description')(value); + + return Promise.resolve(value); + }} + handleChangeChecklists={handleTaskFieldChange('checklists')} + value={currentTask.description || ''} + listVariables={listSystemVariables} + templateVariables={templateVariables} + accountId={accountId} + /> +
+ ); +} diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx new file mode 100644 index 000000000..dfb1a8c01 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { useLayoutEffect, useRef } from 'react'; +import { useIntl } from 'react-intl'; + +import { TUserListItem } from '../../../types/user'; +import { IKickoff, ITemplateTask } from '../../../types/template'; +import { TTaskVariable, TTaskFormPart, ETaskFormParts } from '../types'; +import { TPatchTaskPayload } from '../../../redux/actions'; +import { scrollToElement } from '../../../utils/helpers'; +import { ShowMore } from '../../UI/ShowMore'; +import { useTaskFormParts } from './useTaskFormParts'; + +import styles from '../TemplateEdit.css'; + +interface ITaskFormSectionsProps { + accountId: number; + currentTask: ITemplateTask; + isSubscribed: boolean; + isTeamInvitesModalOpen: boolean; + kickoff: IKickoff; + listVariables: TTaskVariable[]; + scrollTarget: TTaskFormPart; + tasks: ITemplateTask[]; + templateId: number | undefined; + users: TUserListItem[]; + wrapperRef: React.RefObject; + handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; + patchTask(args: TPatchTaskPayload): void; + setCurrentTask(changedFields: Partial): void; +} + +export function TaskFormSections({ + accountId, + currentTask, + handleTaskFieldChange, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + patchTask, + scrollTarget, + setCurrentTask, + tasks, + templateId, + users, + wrapperRef, +}: ITaskFormSectionsProps) { + const { formatMessage } = useIntl(); + const taskFormPartsRefs = { + [ETaskFormParts.AssignPerformers]: useRef(null), + [ETaskFormParts.DueIn]: useRef(null), + [ETaskFormParts.Fields]: useRef(null), + [ETaskFormParts.StartsAfter]: useRef(null), + [ETaskFormParts.CheckIf]: useRef(null), + [ETaskFormParts.ReturnTo]: useRef(null), + }; + const taskFormParts = useTaskFormParts({ + accountId, + currentTask, + handleTaskFieldChange, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + patchTask, + isFieldsSectionShown: ETaskFormParts.Fields === scrollTarget, + setCurrentTask, + tasks, + templateId, + users, + }); + + useLayoutEffect(() => { + const scrollTo = (scrollTarget && taskFormPartsRefs[scrollTarget]?.current) || wrapperRef.current; + + if (scrollTo) scrollToElement(scrollTo); + }, []); + + return ( + <> + {taskFormParts.map(({ title, component, formPartId, widget }) => ( + + {component} + + ))} + + ); +} diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx new file mode 100644 index 000000000..4280b3c71 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useIntl } from 'react-intl'; + +import { TUserListItem } from '../../../types/user'; +import { IKickoff, ITemplateTask } from '../../../types/template'; +import { TTaskVariable, ETaskFormParts } from '../types'; +import { TPatchTaskPayload } from '../../../redux/actions'; +import { OutputFormIntl } from '../OutputForm'; +import { StepName } from '../../StepName'; +import { TaskItemUsers } from '../TaskItem/TaskItemUsers'; +import { TaskRenderDueIn } from '../TaskRenderDueInInfo'; +import { TaskRenderConditionsInfo } from '../TaskRenderConditionsInfo'; +import { TaskRenderExtraFieldsInfo } from '../TaskRenderExtraFieldsInfo'; +import { TaskRenderReturnInfo } from '../TaskRenderReturnInfo'; +import { DueDate } from './DueDate'; +import { ReturnTo } from './ReturnTo'; +import { TaskPerformers } from './TaskPerformers'; +import { CheckIfConditions, ICondition, removeDeletedTasks, StartAfterCondition } from './Conditions'; +import { EStartingType } from './Conditions/utils/getDropdownOperators'; + +import styles from '../TemplateEdit.css'; + +interface IUseTaskFormPartsProps { + accountId: number; + currentTask: ITemplateTask; + isSubscribed: boolean; + isTeamInvitesModalOpen: boolean; + kickoff: IKickoff; + listVariables: TTaskVariable[]; + isFieldsSectionShown: boolean; + tasks: ITemplateTask[]; + templateId: number | undefined; + users: TUserListItem[]; + handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; + patchTask(args: TPatchTaskPayload): void; + setCurrentTask(changedFields: Partial): void; +} + +interface IWidgetProps { + task: ITemplateTask; + isInTaskForm?: boolean; + isStartTask?: boolean; +} + +export interface ITaskFormPart { + formPartId: ETaskFormParts; + title: string; + component: React.ReactNode; + widget(toggle: () => void): React.ReactNode; +} + +export function useTaskFormParts({ + accountId, + currentTask, + handleTaskFieldChange, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + patchTask, + isFieldsSectionShown, + setCurrentTask, + tasks, + templateId, + users, +}: IUseTaskFormPartsProps): ITaskFormPart[] { + const { formatMessage } = useIntl(); + const startingOrder: TTaskVariable[] = useMemo(() => [ + { + title: formatMessage({ id: 'templates.conditions.starting-order.kick-off' }), + apiName: 'kick-off', + type: EStartingType.Kickoff, + }, + ...tasks + .filter((localTask) => currentTask.apiName !== localTask.apiName) + .map((localTask) => ({ + apiName: localTask.apiName, + title: localTask.name, + type: EStartingType.Task, + richSubtitle: , + })), + ], [currentTask.apiName, formatMessage, tasks, templateId]); + + const onEdit = useCallback( + (conditions: ICondition[]) => { + patchTask({ taskUUID: currentTask.uuid, changedFields: { conditions } }); + }, + [patchTask, currentTask.uuid], + ); + + useEffect(() => { + removeDeletedTasks(startingOrder, currentTask.conditions, onEdit); + }, [startingOrder, currentTask.conditions, onEdit]); + + const createWidget = useCallback( + (Component: (props: IWidgetProps & { onClick: () => void }) => React.ReactNode, props: IWidgetProps) => { + return (toggle: () => void) => Component({ + task: props.task, + onClick: toggle, + isInTaskForm: props.isInTaskForm, + isStartTask: props.isStartTask, + }); + }, + [], + ); + + return [ + { + formPartId: ETaskFormParts.AssignPerformers, + title: 'tasks.task-assign-help', + component: ( + + ), + widget: createWidget(TaskItemUsers, { task: currentTask }), + }, + { + formPartId: ETaskFormParts.DueIn, + title: 'tasks.task-due-date', + component: ( +
+ +
+ ), + widget: createWidget(TaskRenderDueIn, { task: currentTask, isInTaskForm: true }), + }, + { + formPartId: ETaskFormParts.Fields, + title: 'tasks.task-outputs-create-help', + component: ( + + ), + widget: createWidget(TaskRenderExtraFieldsInfo, { task: currentTask }), + }, + { + formPartId: ETaskFormParts.StartsAfter, + title: 'templates.starts-after.title', + component: ( + + ), + widget: createWidget(TaskRenderConditionsInfo, { + task: currentTask, + isInTaskForm: true, + isStartTask: true, + }), + }, + { + formPartId: ETaskFormParts.CheckIf, + title: 'templates.conditions.check-if-title', + component: ( + + ), + widget: createWidget(TaskRenderConditionsInfo, { task: currentTask, isInTaskForm: true, isStartTask: false }), + }, + { + formPartId: ETaskFormParts.ReturnTo, + title: 'templates.return-to.title', + component: ( + + ), + widget: createWidget(TaskRenderReturnInfo, { task: currentTask }), + }, + ]; +} diff --git a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx index e69b192c7..a22337e29 100644 --- a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx +++ b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import { useIntl } from 'react-intl'; import { Avatar } from '../../../../UI/Avatar'; @@ -28,9 +28,9 @@ export function WorkflowLogTaskComplete({ const { formatMessage } = useIntl(); const renderOutputValues = () => { - const hasOutputValue = isArrayWithItems(currentTask?.output.filter(Boolean)); + const outputs = currentTask?.output?.filter(Boolean) || []; - if (!hasOutputValue) { + if (!isArrayWithItems(outputs)) { return null; } @@ -38,7 +38,7 @@ export function WorkflowLogTaskComplete({ ); diff --git a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx new file mode 100644 index 000000000..f4820d43f --- /dev/null +++ b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import { WorkflowLogTaskComplete } from '../WorkflowLogTaskComplete'; + +jest.mock('../../../../../UserData', () => ({ + UserData: ({ children }: { children(user: unknown): React.ReactNode }) => children({ firstName: 'Test', lastName: 'User' }), +})); + +jest.mock('../../../../../KickoffOutputs', () => ({ + EKickoffOutputsViewModes: { Short: 'short' }, + KickoffOutputs: () =>
, +})); + +jest.mock('../../../../../UI/Avatar', () => ({ + Avatar: () =>
, +})); + +jest.mock('../../../../../UI/DateFormat', () => ({ + DateFormat: () => , +})); + +describe('WorkflowLogTaskComplete', () => { + it('does not crash when completed task has no output', () => { + render( + + + , + ); + + expect(screen.queryByTestId('outputs')).not.toBeInTheDocument(); + }); +}); From c94187b3eda41b0297b21f31814f2fb8d98f60cd Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Mon, 29 Jun 2026 11:05:40 +0300 Subject: [PATCH 02/39] 59876 fix(template): render task form widgets as components --- .../TemplateEdit/TaskForm/useTaskFormParts.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx index 4280b3c71..c107c5d60 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx @@ -94,13 +94,15 @@ export function useTaskFormParts({ }, [startingOrder, currentTask.conditions, onEdit]); const createWidget = useCallback( - (Component: (props: IWidgetProps & { onClick: () => void }) => React.ReactNode, props: IWidgetProps) => { - return (toggle: () => void) => Component({ - task: props.task, - onClick: toggle, - isInTaskForm: props.isInTaskForm, - isStartTask: props.isStartTask, - }); + (Component: React.ComponentType void }>, props: IWidgetProps) => { + return (toggle: () => void) => ( + + ); }, [], ); From bcc0aca6efc47c93fa9281944aaadd81e2dac5af Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Mon, 29 Jun 2026 11:11:40 +0300 Subject: [PATCH 03/39] 59876 fix(template): clean up task form widget types --- .../components/TemplateEdit/TaskForm/TaskForm.tsx | 1 - .../TemplateEdit/TaskForm/TaskFormSections.tsx | 4 ---- .../TemplateEdit/TaskForm/useTaskFormParts.tsx | 13 +++++-------- .../TaskRenderExtraFieldsInfo.tsx | 12 +++++++----- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx index 2e6ca0d60..de1dc73f5 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -84,7 +84,6 @@ export function TaskForm({ isTeamInvitesModalOpen={isTeamInvitesModalOpen} kickoff={kickoff} listVariables={listVariables} - patchTask={patchTask} scrollTarget={scrollTarget} setCurrentTask={setCurrentTask} tasks={tasks} diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx index dfb1a8c01..40586ccd3 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx @@ -5,7 +5,6 @@ import { useIntl } from 'react-intl'; import { TUserListItem } from '../../../types/user'; import { IKickoff, ITemplateTask } from '../../../types/template'; import { TTaskVariable, TTaskFormPart, ETaskFormParts } from '../types'; -import { TPatchTaskPayload } from '../../../redux/actions'; import { scrollToElement } from '../../../utils/helpers'; import { ShowMore } from '../../UI/ShowMore'; import { useTaskFormParts } from './useTaskFormParts'; @@ -25,7 +24,6 @@ interface ITaskFormSectionsProps { users: TUserListItem[]; wrapperRef: React.RefObject; handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; - patchTask(args: TPatchTaskPayload): void; setCurrentTask(changedFields: Partial): void; } @@ -37,7 +35,6 @@ export function TaskFormSections({ isTeamInvitesModalOpen, kickoff, listVariables, - patchTask, scrollTarget, setCurrentTask, tasks, @@ -62,7 +59,6 @@ export function TaskFormSections({ isTeamInvitesModalOpen, kickoff, listVariables, - patchTask, isFieldsSectionShown: ETaskFormParts.Fields === scrollTarget, setCurrentTask, tasks, diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx index c107c5d60..4ec6b6adc 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx @@ -5,7 +5,6 @@ import { useIntl } from 'react-intl'; import { TUserListItem } from '../../../types/user'; import { IKickoff, ITemplateTask } from '../../../types/template'; import { TTaskVariable, ETaskFormParts } from '../types'; -import { TPatchTaskPayload } from '../../../redux/actions'; import { OutputFormIntl } from '../OutputForm'; import { StepName } from '../../StepName'; import { TaskItemUsers } from '../TaskItem/TaskItemUsers'; @@ -33,7 +32,6 @@ interface IUseTaskFormPartsProps { templateId: number | undefined; users: TUserListItem[]; handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; - patchTask(args: TPatchTaskPayload): void; setCurrentTask(changedFields: Partial): void; } @@ -58,7 +56,6 @@ export function useTaskFormParts({ isTeamInvitesModalOpen, kickoff, listVariables, - patchTask, isFieldsSectionShown, setCurrentTask, tasks, @@ -82,16 +79,16 @@ export function useTaskFormParts({ })), ], [currentTask.apiName, formatMessage, tasks, templateId]); - const onEdit = useCallback( + const onRemoveDeletedTasks = useCallback( (conditions: ICondition[]) => { - patchTask({ taskUUID: currentTask.uuid, changedFields: { conditions } }); + setCurrentTask({ conditions }); }, - [patchTask, currentTask.uuid], + [setCurrentTask], ); useEffect(() => { - removeDeletedTasks(startingOrder, currentTask.conditions, onEdit); - }, [startingOrder, currentTask.conditions, onEdit]); + removeDeletedTasks(startingOrder, currentTask.conditions, onRemoveDeletedTasks); + }, [startingOrder, currentTask.conditions, onRemoveDeletedTasks]); const createWidget = useCallback( (Component: React.ComponentType void }>, props: IWidgetProps) => { diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx index da813c38b..6601dc82c 100644 --- a/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx @@ -18,11 +18,13 @@ export const TaskRenderExtraFieldsInfo = ({ task: { fields }, onClick }: ITaskRe formatMessage, }); + if (!fields.length) { + return null; + } + return ( - fields.length > 0 && ( - - ) + ); }; From c9217f7d3cd3f1b05da759d7d2cfcfee187cd116 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 12:40:43 +0300 Subject: [PATCH 04/39] 59876 refactor(template): bind task form fields to Formik and centralize persist Wire task form sections through useTaskForm so fields read and write Formik state, and persist template changes from a single onChange path in TaskFormPersistProvider. Co-authored-by: Cursor --- .../TaskForm/ReturnTo/ReturnTo.tsx | 18 +-- .../TemplateEdit/TaskForm/TaskForm.tsx | 58 +++----- .../TemplateEdit/TaskForm/TaskFormHeader.tsx | 17 +-- .../TaskForm/TaskFormSections.tsx | 9 -- .../TemplateEdit/TaskForm/TaskPerformers.tsx | 16 +-- .../__tests__/TaskPerformers.test.tsx | 134 ++++++++++++------ .../TemplateEdit/TaskForm/useTaskForm.tsx | 82 +++++++++++ .../TaskForm/useTaskFormParts.tsx | 56 ++++---- 8 files changed, 241 insertions(+), 149 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx index f97239330..66bbfbe3e 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx @@ -11,6 +11,7 @@ import { useCheckDevice } from '../../../../hooks/useCheckDevice'; import { TTaskVariable } from '../../types'; import { ITemplateTask } from '../../../../types/template'; +import { useTaskForm } from '../useTaskForm'; import styles from './ReturnTo.css'; import stylesTaskForm from '../TaskForm.css'; @@ -18,8 +19,6 @@ import stylesTaskForm from '../TaskForm.css'; interface IReturnToProps { variables: TTaskVariable[]; tasks: ITemplateTask[]; - currentTaskRevertTask: string | null; - setCurrentTask(changedFields: Partial): void; taskAncestors: Set; } @@ -29,8 +28,9 @@ interface IDropdownTask { richLabel: ReactNode; } -export function ReturnTo({ variables, tasks, currentTaskRevertTask, setCurrentTask, taskAncestors }: IReturnToProps) { - const [isReturnTo, setIsReturnTo] = useState(Boolean(currentTaskRevertTask)); +export function ReturnTo({ variables, tasks, taskAncestors }: IReturnToProps) { + const { task, updateTask } = useTaskForm(); + const [isReturnTo, setIsReturnTo] = useState(Boolean(task.revertTask)); const { formatMessage } = useIntl(); const { isMobile } = useCheckDevice(); const STYLES = { @@ -57,7 +57,7 @@ export function ReturnTo({ variables, tasks, currentTaskRevertTask, setCurrentTa const dropdownTaskList = useMemo( () => [ ...tasks - .filter((task: ITemplateTask) => taskAncestors.has(task.apiName)) + .filter((ancestorTask: ITemplateTask) => taskAncestors.has(ancestorTask.apiName)) .map(({ name, apiName }) => { return { label: name, @@ -74,25 +74,25 @@ export function ReturnTo({ variables, tasks, currentTaskRevertTask, setCurrentTa ); const selectedTask = - currentTaskRevertTask && dropdownTaskList.find((task: IDropdownTask) => currentTaskRevertTask === task.apiName); + task.revertTask && dropdownTaskList.find((dropdownTask: IDropdownTask) => task.revertTask === dropdownTask.apiName); const formatOptionLabel = (option: IDropdownTask, { context }: { context: string }) => { return context === 'menu' ? getFormattedDropdownOption({ label: option.richLabel, - isSelected: option.apiName === currentTaskRevertTask, + isSelected: option.apiName === task.revertTask, }) : option.richLabel; }; const removeReturn = () => { setIsReturnTo(false); - setCurrentTask({ revertTask: null }); + updateTask({ revertTask: null }); }; const handleOptionChange = (option: IDropdownTask) => { if (selectedTask && option.apiName === selectedTask.apiName) return; - setCurrentTask({ revertTask: option.apiName }); + updateTask({ revertTask: option.apiName }); }; return ( diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx index de1dc73f5..1d04d50b1 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -10,6 +10,7 @@ import { TPatchTaskPayload } from '../../../redux/actions'; import { getSystemVariables } from './utils/getTaskVariables'; import { TaskFormHeader } from './TaskFormHeader'; import { TaskFormSections } from './TaskFormSections'; +import { TaskFormPersistProvider } from './useTaskForm'; import styles from '../TemplateEdit.css'; @@ -49,50 +50,37 @@ export function TaskForm({ enableReinitialize: true, onSubmit: () => undefined, }); - const currentTask = formik.values; const listSystemVariables = useMemo(() => [ ...getSystemVariables(), ...listVariables, ], [listVariables]); - const setCurrentTask = (changedFields: Partial) => { - Object.entries(changedFields).forEach(([field, value]) => formik.setFieldValue(field, value, false)); - patchTask({ taskUUID: currentTask.uuid, changedFields }); - }; - - const handleTaskFieldChange = (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => { - setCurrentTask({ [field]: value }); - }; - return ( -
-
- + +
+
+ - + +
-
+ ); } diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx index 48b7bd97d..a93a84667 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx @@ -1,30 +1,27 @@ import * as React from 'react'; import { useIntl } from 'react-intl'; -import { ITemplateTask } from '../../../types/template'; import { TTaskVariable } from '../types'; import { InputWithVariables } from '../InputWithVariables'; import { TaskDescriptionEditor } from './TaskDescriptionEditor'; import { getSingleLineVariables } from './utils/getTaskVariables'; +import { useTaskForm } from './useTaskForm'; import styles from '../TemplateEdit.css'; interface ITaskFormHeaderProps { accountId: number; - currentTask: ITemplateTask; listSystemVariables: TTaskVariable[]; templateVariables: TTaskVariable[]; - handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; } export function TaskFormHeader({ accountId, - currentTask, - handleTaskFieldChange, listSystemVariables, templateVariables, }: ITaskFormHeaderProps) { const { formatMessage } = useIntl(); + const { task, updateField } = useTaskForm(); return (
@@ -32,9 +29,9 @@ export function TaskFormHeader({ placeholder={formatMessage({ id: 'tasks.task-task-name-placeholder' })} listVariables={getSingleLineVariables(listSystemVariables)} templateVariables={templateVariables} - value={currentTask.name || ''} + value={task.name || ''} onChange={(value: string) => { - handleTaskFieldChange('name')(value); + updateField('name')(value); return Promise.resolve(value); }} @@ -43,12 +40,12 @@ export function TaskFormHeader({ /> { - handleTaskFieldChange('description')(value); + updateField('description')(value); return Promise.resolve(value); }} - handleChangeChecklists={handleTaskFieldChange('checklists')} - value={currentTask.description || ''} + handleChangeChecklists={updateField('checklists')} + value={task.description || ''} listVariables={listSystemVariables} templateVariables={templateVariables} accountId={accountId} diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx index 40586ccd3..2f864d64b 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx @@ -13,7 +13,6 @@ import styles from '../TemplateEdit.css'; interface ITaskFormSectionsProps { accountId: number; - currentTask: ITemplateTask; isSubscribed: boolean; isTeamInvitesModalOpen: boolean; kickoff: IKickoff; @@ -23,20 +22,15 @@ interface ITaskFormSectionsProps { templateId: number | undefined; users: TUserListItem[]; wrapperRef: React.RefObject; - handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; - setCurrentTask(changedFields: Partial): void; } export function TaskFormSections({ accountId, - currentTask, - handleTaskFieldChange, isSubscribed, isTeamInvitesModalOpen, kickoff, listVariables, scrollTarget, - setCurrentTask, tasks, templateId, users, @@ -53,14 +47,11 @@ export function TaskFormSections({ }; const taskFormParts = useTaskFormParts({ accountId, - currentTask, - handleTaskFieldChange, isSubscribed, isTeamInvitesModalOpen, kickoff, listVariables, isFieldsSectionShown: ETaskFormParts.Fields === scrollTarget, - setCurrentTask, tasks, templateId, users, diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx index cba309c93..b5fcbccfc 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx @@ -13,23 +13,23 @@ import { getUserFullName } from '../../../utils/users'; import { getPerformersForDropdown } from './utils/getPerformersForDropdown'; import { EBgColorTypes, UserPerformer } from '../../UI/UserPerformer'; import { getRegularGroupsList } from '../../../redux/selectors/groups'; +import { useTaskForm } from './useTaskForm'; import styles from '../TemplateEdit.css'; import stylesTaskForm from './TaskForm.css'; import { createPerformerApiName } from '../../../utils/createId'; export interface ITaskPerformersProps { - task: ITemplateTask; tasks: ITemplateTask[]; users: TUserListItem[]; variables: TTaskVariable[]; isTeamInvitesModalOpen: boolean; - setCurrentTask(changedFields: Partial): void; } -export function TaskPerformers({ task, tasks, users, variables, setCurrentTask }: ITaskPerformersProps) { +export function TaskPerformers({ tasks, users, variables }: ITaskPerformersProps) { const { formatMessage } = useIntl(); const groups = useSelector(getRegularGroupsList); + const { task, updateTask } = useTaskForm(); const { rawPerformers = [] } = task; @@ -83,13 +83,13 @@ export function TaskPerformers({ task, tasks, users, variables, setCurrentTask } apiName: createPerformerApiName(), })); - setCurrentTask({ rawPerformers: [...rawPerformers, ...invitedPeformers] }); + updateTask({ rawPerformers: [...rawPerformers, ...invitedPeformers] }); }; const handleAddPerformer = (performer: ITemplateTaskPerformer) => { const normalizedPerformer = { ...performer, apiName: createPerformerApiName() }; delete (normalizedPerformer as any).id; - setCurrentTask({ rawPerformers: [...rawPerformers, normalizedPerformer] as ITemplateTaskPerformer[] }); + updateTask({ rawPerformers: [...rawPerformers, normalizedPerformer] as ITemplateTaskPerformer[] }); }; const handleRemovePerformer = (removingPerformer: ITemplateTaskPerformer) => { @@ -100,11 +100,11 @@ export function TaskPerformers({ task, tasks, users, variables, setCurrentTask } ].every(Boolean); }); - setCurrentTask({ rawPerformers: newPerformers }); + updateTask({ rawPerformers: newPerformers }); }; const handleRequireCompletionByAllChange = (value: boolean) => { - setCurrentTask({ requireCompletionByAll: value }); + updateTask({ requireCompletionByAll: value }); }; const renderPerformers = () => { @@ -140,7 +140,7 @@ export function TaskPerformers({ task, tasks, users, variables, setCurrentTask } checkboxId={`skipForStarter-${task.apiName}`} title={formatMessage({ id: 'templates.task-skip-for-starter' })} checked={task.skipForStarter} - onChange={(e) => setCurrentTask({ skipForStarter: e.currentTarget.checked })} + onChange={(e) => updateTask({ skipForStarter: e.currentTarget.checked })} />
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx index 0e5baf7e1..3ae403bd1 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx @@ -1,10 +1,12 @@ /// import * as React from 'react'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; +import { FormikProvider, useFormik } from 'formik'; import { intlMock } from '../../../../__stubs__/intlMock'; import { Checkbox } from '../../../UI/Fields/Checkbox'; import { ITemplateTask } from '../../../../types/template'; +import { TaskFormPersistProvider } from '../useTaskForm'; // TaskPerformers uses `import React from 'react'` (default import), // which doesn't work with ts-jest without esModuleInterop. @@ -51,7 +53,7 @@ describe('TaskPerformers', () => { const t = (id: string) => intlMock.formatMessage({ id }); const SKIP_LABEL = t('templates.task-skip-for-starter'); - const mockSetCurrentTask = jest.fn(); + const mockPatchTask = jest.fn(); const makeTask = (overrides: Partial = {}): ITemplateTask => ({ apiName: 'task-1', @@ -72,17 +74,42 @@ describe('TaskPerformers', () => { ...overrides, }); + const createTaskFormWrapper = (task: ITemplateTask, patchTask = mockPatchTask) => { + function TaskFormTestWrapper({ children }: { children: React.ReactNode }) { + const formik = useFormik({ + initialValues: task, + enableReinitialize: true, + onSubmit: () => undefined, + }); + + return React.createElement( + FormikProvider, + { value: formik }, + React.createElement( + TaskFormPersistProvider, + { patchTask, task, children }, + ), + ); + } + + return TaskFormTestWrapper; + }; + const renderComponent = (taskOverrides: Partial = {}) => { const task = makeTask(taskOverrides); + const Wrapper = createTaskFormWrapper(task); + return render( - React.createElement(TaskPerformers, { - task, - tasks: [task], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - setCurrentTask: mockSetCurrentTask, - }), + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), ); }; @@ -103,31 +130,37 @@ describe('TaskPerformers', () => { const renderTwoTasks = () => { const task1 = makeTask({ apiName: 'task-1' }); const task2 = makeTask({ apiName: 'task-2' }); - const setCurrentTask1 = jest.fn(); - const setCurrentTask2 = jest.fn(); + const mockPatchTask1 = jest.fn(); + const mockPatchTask2 = jest.fn(); + const Wrapper1 = createTaskFormWrapper(task1, mockPatchTask1); + const Wrapper2 = createTaskFormWrapper(task2, mockPatchTask2); render( React.createElement('div', null, - React.createElement(TaskPerformers, { - task: task1, - tasks: [task1, task2], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - setCurrentTask: setCurrentTask1, - }), - React.createElement(TaskPerformers, { - task: task2, - tasks: [task1, task2], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - setCurrentTask: setCurrentTask2, - }), + React.createElement( + Wrapper1, + null, + React.createElement(TaskPerformers, { + tasks: [task1, task2], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + React.createElement( + Wrapper2, + null, + React.createElement(TaskPerformers, { + tasks: [task1, task2], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), ), ); - return { setCurrentTask1, setCurrentTask2 }; + return { mockPatchTask1, mockPatchTask2 }; }; beforeEach(() => { @@ -158,8 +191,8 @@ describe('TaskPerformers', () => { const completeByAllIds = checkboxIds.filter((id: string) => id?.startsWith('completeByAll-')); const skipForStarterIds = checkboxIds.filter((id: string) => id?.startsWith('skipForStarter-')); - expect(completeByAllIds).toEqual(['completeByAll-task-1', 'completeByAll-task-2']); - expect(skipForStarterIds).toEqual(['skipForStarter-task-1', 'skipForStarter-task-2']); + expect([...new Set(completeByAllIds)]).toEqual(['completeByAll-task-1', 'completeByAll-task-2']); + expect([...new Set(skipForStarterIds)]).toEqual(['skipForStarter-task-1', 'skipForStarter-task-2']); }); it('uses checkboxId prop (not id) to ensure proper label-input binding', () => { @@ -200,16 +233,19 @@ describe('TaskPerformers', () => { expect(call![0].checked).toBe(false); }); - it('calls setCurrentTask with requireCompletionByAll on onChange', () => { + it('calls patchTask with requireCompletionByAll on onChange', () => { renderComponent({ requireCompletionByAll: false }); const call = getCompleteByAllCall(); const onChangeFn = call![0].onChange; - onChangeFn({ currentTarget: { checked: true } }); + act(() => { + onChangeFn({ currentTarget: { checked: true } }); + }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { requireCompletionByAll: true }, - ); + expect(mockPatchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { requireCompletionByAll: true }, + }); }); }); @@ -239,28 +275,34 @@ describe('TaskPerformers', () => { expect(skipCall![0].checked).toBe(false); }); - it('calls setCurrentTask with true on onChange', () => { + it('calls patchTask with true on onChange', () => { renderComponent({ skipForStarter: false }); const skipCall = getSkipCheckboxCall(); const onChangeFn = skipCall![0].onChange; - onChangeFn({ currentTarget: { checked: true } }); + act(() => { + onChangeFn({ currentTarget: { checked: true } }); + }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { skipForStarter: true }, - ); + expect(mockPatchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { skipForStarter: true }, + }); }); - it('calls setCurrentTask with false on checkbox uncheck', () => { + it('calls patchTask with false on checkbox uncheck', () => { renderComponent({ skipForStarter: true }); const skipCall = getSkipCheckboxCall(); const onChangeFn = skipCall![0].onChange; - onChangeFn({ currentTarget: { checked: false } }); + act(() => { + onChangeFn({ currentTarget: { checked: false } }); + }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { skipForStarter: false }, - ); + expect(mockPatchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { skipForStarter: false }, + }); }); }); }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx new file mode 100644 index 000000000..20c207309 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { useFormikContext } from 'formik'; + +import { ITemplateTask } from '../../../types/template'; +import { TPatchTaskPayload } from '../../../redux/actions'; + +type TPatchTaskFn = (payload: TPatchTaskPayload) => void; + +interface ITaskFormPersistProviderProps { + patchTask: TPatchTaskFn; + task: ITemplateTask; + children: React.ReactNode; +} + +export function TaskFormPersistProvider({ patchTask, task, children }: ITaskFormPersistProviderProps) { + const { values } = useFormikContext(); + const previousValuesRef = useRef(values); + const externalTaskRef = useRef(task); + + useEffect(() => { + // The task prop changed from the store (reinitialize): adopt the new + // Formik values as the baseline instead of echoing them back as a patch. + if (externalTaskRef.current !== task) { + externalTaskRef.current = task; + previousValuesRef.current = values; + + return; + } + + if (previousValuesRef.current === values) { + return; + } + + const changedFields = getChangedFields(previousValuesRef.current, values); + previousValuesRef.current = values; + + if (Object.keys(changedFields).length > 0) { + patchTask({ taskUUID: task.uuid, changedFields }); + } + }, [values, task, patchTask]); + + return children; +} + +function getChangedFields(previous: ITemplateTask, next: ITemplateTask): Partial { + const changedFields: Partial = {}; + + (Object.keys(next) as (keyof ITemplateTask)[]).forEach((key) => { + if (previous[key] !== next[key]) { + (changedFields[key] as ITemplateTask[keyof ITemplateTask]) = next[key]; + } + }); + + return changedFields; +} + +export function useTaskForm() { + const { values, setFieldValue } = useFormikContext(); + + const updateTask = useCallback( + (changedFields: Partial) => { + Object.entries(changedFields).forEach(([field, value]) => { + setFieldValue(field, value, false); + }); + }, + [setFieldValue], + ); + + const updateField = useCallback( + (field: K) => (value: ITemplateTask[K]) => { + setFieldValue(field, value, false); + }, + [setFieldValue], + ); + + return { + task: values, + updateTask, + updateField, + }; +} diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx index 4ec6b6adc..b03fa173a 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx @@ -17,12 +17,12 @@ import { ReturnTo } from './ReturnTo'; import { TaskPerformers } from './TaskPerformers'; import { CheckIfConditions, ICondition, removeDeletedTasks, StartAfterCondition } from './Conditions'; import { EStartingType } from './Conditions/utils/getDropdownOperators'; +import { useTaskForm } from './useTaskForm'; import styles from '../TemplateEdit.css'; interface IUseTaskFormPartsProps { accountId: number; - currentTask: ITemplateTask; isSubscribed: boolean; isTeamInvitesModalOpen: boolean; kickoff: IKickoff; @@ -31,8 +31,6 @@ interface IUseTaskFormPartsProps { tasks: ITemplateTask[]; templateId: number | undefined; users: TUserListItem[]; - handleTaskFieldChange(field: keyof ITemplateTask): (value: ITemplateTask[keyof ITemplateTask]) => void; - setCurrentTask(changedFields: Partial): void; } interface IWidgetProps { @@ -50,19 +48,17 @@ export interface ITaskFormPart { export function useTaskFormParts({ accountId, - currentTask, - handleTaskFieldChange, isSubscribed, isTeamInvitesModalOpen, kickoff, listVariables, isFieldsSectionShown, - setCurrentTask, tasks, templateId, users, }: IUseTaskFormPartsProps): ITaskFormPart[] { const { formatMessage } = useIntl(); + const { task, updateTask, updateField } = useTaskForm(); const startingOrder: TTaskVariable[] = useMemo(() => [ { title: formatMessage({ id: 'templates.conditions.starting-order.kick-off' }), @@ -70,25 +66,25 @@ export function useTaskFormParts({ type: EStartingType.Kickoff, }, ...tasks - .filter((localTask) => currentTask.apiName !== localTask.apiName) + .filter((localTask) => task.apiName !== localTask.apiName) .map((localTask) => ({ apiName: localTask.apiName, title: localTask.name, type: EStartingType.Task, richSubtitle: , })), - ], [currentTask.apiName, formatMessage, tasks, templateId]); + ], [task.apiName, formatMessage, tasks, templateId]); const onRemoveDeletedTasks = useCallback( (conditions: ICondition[]) => { - setCurrentTask({ conditions }); + updateTask({ conditions }); }, - [setCurrentTask], + [updateTask], ); useEffect(() => { - removeDeletedTasks(startingOrder, currentTask.conditions, onRemoveDeletedTasks); - }, [startingOrder, currentTask.conditions, onRemoveDeletedTasks]); + removeDeletedTasks(startingOrder, task.conditions, onRemoveDeletedTasks); + }, [startingOrder, task.conditions, onRemoveDeletedTasks]); const createWidget = useCallback( (Component: React.ComponentType void }>, props: IWidgetProps) => { @@ -111,14 +107,12 @@ export function useTaskFormParts({ component: ( ), - widget: createWidget(TaskItemUsers, { task: currentTask }), + widget: createWidget(TaskItemUsers, { task }), }, { formPartId: ETaskFormParts.DueIn, @@ -126,29 +120,29 @@ export function useTaskFormParts({ component: (
), - widget: createWidget(TaskRenderDueIn, { task: currentTask, isInTaskForm: true }), + widget: createWidget(TaskRenderDueIn, { task, isInTaskForm: true }), }, { formPartId: ETaskFormParts.Fields, title: 'tasks.task-outputs-create-help', component: ( ), - widget: createWidget(TaskRenderExtraFieldsInfo, { task: currentTask }), + widget: createWidget(TaskRenderExtraFieldsInfo, { task }), }, { formPartId: ETaskFormParts.StartsAfter, @@ -156,15 +150,15 @@ export function useTaskFormParts({ component: ( ), widget: createWidget(TaskRenderConditionsInfo, { - task: currentTask, + task, isInTaskForm: true, isStartTask: true, }), @@ -175,14 +169,14 @@ export function useTaskFormParts({ component: ( ), - widget: createWidget(TaskRenderConditionsInfo, { task: currentTask, isInTaskForm: true, isStartTask: false }), + widget: createWidget(TaskRenderConditionsInfo, { task, isInTaskForm: true, isStartTask: false }), }, { formPartId: ETaskFormParts.ReturnTo, @@ -191,12 +185,10 @@ export function useTaskFormParts({ ), - widget: createWidget(TaskRenderReturnInfo, { task: currentTask }), + widget: createWidget(TaskRenderReturnInfo, { task }), }, ]; } From 1be380892a0e9797b0a69ff93cd88ac39d70fe8a Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 13:01:04 +0300 Subject: [PATCH 05/39] 59876 refactor(template): stabilize Formik task persistence --- .../__tests__/TaskPerformers.test.tsx | 14 +- .../TaskForm/__tests__/useTaskForm.test.tsx | 133 ++++++++++++++++++ .../TemplateEdit/TaskForm/useTaskForm.tsx | 48 ++++--- 3 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx index 03fb0cdb3..09125fe75 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx @@ -7,6 +7,7 @@ import { intlMock } from '../../../../__stubs__/intlMock'; import { Checkbox } from '../../../UI/Fields/Checkbox'; import { ITemplateTask } from '../../../../types/template'; import { TaskPerformers } from '../TaskPerformers'; +import { TaskFormPersistProvider } from '../useTaskForm'; jest.mock('../../../UI/Fields/Checkbox', () => ({ Checkbox: jest.fn(() => null), @@ -62,6 +63,10 @@ describe('TaskPerformers', () => { ...overrides, }); + const flushPersist = () => act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const createTaskFormWrapper = (task: ITemplateTask, patchTask = mockPatchTask) => { function TaskFormTestWrapper({ children }: { children: React.ReactNode }) { const formik = useFormik({ @@ -221,7 +226,7 @@ describe('TaskPerformers', () => { expect(call![0].checked).toBe(false); }); - it('calls patchTask with requireCompletionByAll on onChange', () => { + it('calls patchTask with requireCompletionByAll on onChange', async () => { renderComponent({ requireCompletionByAll: false }); const call = getCompleteByAllCall(); @@ -229,6 +234,7 @@ describe('TaskPerformers', () => { act(() => { onChangeFn({ currentTarget: { checked: true } }); }); + await flushPersist(); expect(mockPatchTask).toHaveBeenCalledWith({ taskUUID: 'uuid-1', @@ -263,7 +269,7 @@ describe('TaskPerformers', () => { expect(skipCall![0].checked).toBe(false); }); - it('calls patchTask with true on onChange', () => { + it('calls patchTask with true on onChange', async () => { renderComponent({ skipForStarter: false }); const skipCall = getSkipCheckboxCall(); @@ -271,6 +277,7 @@ describe('TaskPerformers', () => { act(() => { onChangeFn({ currentTarget: { checked: true } }); }); + await flushPersist(); expect(mockPatchTask).toHaveBeenCalledWith({ taskUUID: 'uuid-1', @@ -278,7 +285,7 @@ describe('TaskPerformers', () => { }); }); - it('calls patchTask with false on checkbox uncheck', () => { + it('calls patchTask with false on checkbox uncheck', async () => { renderComponent({ skipForStarter: true }); const skipCall = getSkipCheckboxCall(); @@ -286,6 +293,7 @@ describe('TaskPerformers', () => { act(() => { onChangeFn({ currentTarget: { checked: false } }); }); + await flushPersist(); expect(mockPatchTask).toHaveBeenCalledWith({ taskUUID: 'uuid-1', diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx new file mode 100644 index 000000000..46e637ad4 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -0,0 +1,133 @@ +/// +import * as React from 'react'; +import { act, render } from '@testing-library/react'; +import { FormikProvider, useFormik } from 'formik'; + +import { ITemplateTask } from '../../../../types/template'; +import { TaskFormPersistProvider, useTaskForm } from '../useTaskForm'; + +const makeTask = (overrides: Partial = {}): ITemplateTask => ({ + apiName: 'task-1', + number: 1, + name: 'Test Task', + description: '', + delay: null, + rawDueDate: null as any, + requireCompletionByAll: false, + skipForStarter: false, + rawPerformers: [], + fields: [], + uuid: 'uuid-1', + conditions: [], + checklists: [], + revertTask: null, + ancestors: [], + ...overrides, +}); + +const flushPersist = () => act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +interface IHarnessProps { + task: ITemplateTask; + patchTask: jest.Mock; + children: React.ReactNode; +} + +function TaskFormHarness({ task, patchTask, children }: IHarnessProps) { + const formik = useFormik({ + initialValues: task, + enableReinitialize: true, + onSubmit: () => undefined, + }); + + return ( + + + {children} + + + ); +} + +describe('TaskFormPersistProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not patch when the external task prop changes', async () => { + const patchTask = jest.fn(); + const task1 = makeTask({ uuid: 'uuid-1', name: 'Task 1' }); + const task2 = makeTask({ uuid: 'uuid-2', name: 'Task 2' }); + + const { rerender } = render( + + + , + ); + + await flushPersist(); + patchTask.mockClear(); + + rerender( + + + , + ); + + await flushPersist(); + + expect(patchTask).not.toHaveBeenCalled(); + }); + + it('batches rapid field updates into a single patch', async () => { + const patchTask = jest.fn(); + const task = makeTask({ description: 'old', checklists: [] }); + + function DescriptionEditorSimulator() { + const { updateField } = useTaskForm(); + + return ( + + ); + } + + const { getByRole } = render( + + + , + ); + + await flushPersist(); + patchTask.mockClear(); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + await flushPersist(); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { + checklists: [{ + apiName: 'checklist-1', + selections: [{ apiName: 'item-1', value: 'Item' }], + }], + description: 'new description', + }, + }); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index 20c207309..4d541ca90 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -17,30 +17,40 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm const { values } = useFormikContext(); const previousValuesRef = useRef(values); const externalTaskRef = useRef(task); + const skipNextPersistRef = useRef(false); useEffect(() => { - // The task prop changed from the store (reinitialize): adopt the new - // Formik values as the baseline instead of echoing them back as a patch. + let timeoutId: number | undefined; + if (externalTaskRef.current !== task) { externalTaskRef.current = task; + skipNextPersistRef.current = true; + } else if (skipNextPersistRef.current) { + skipNextPersistRef.current = false; previousValuesRef.current = values; - - return; - } - - if (previousValuesRef.current === values) { - return; + } else if (previousValuesRef.current !== values) { + timeoutId = window.setTimeout(() => { + if (previousValuesRef.current === values) { + return; + } + + const changedFields = getChangedFields(previousValuesRef.current, values); + previousValuesRef.current = values; + + if (Object.keys(changedFields).length > 0) { + patchTask({ taskUUID: task.uuid, changedFields }); + } + }, 0); } - const changedFields = getChangedFields(previousValuesRef.current, values); - previousValuesRef.current = values; - - if (Object.keys(changedFields).length > 0) { - patchTask({ taskUUID: task.uuid, changedFields }); - } + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }; }, [values, task, patchTask]); - return children; + return children as React.ReactElement; } function getChangedFields(previous: ITemplateTask, next: ITemplateTask): Partial { @@ -56,15 +66,13 @@ function getChangedFields(previous: ITemplateTask, next: ITemplateTask): Partial } export function useTaskForm() { - const { values, setFieldValue } = useFormikContext(); + const { values, setFieldValue, setValues } = useFormikContext(); const updateTask = useCallback( (changedFields: Partial) => { - Object.entries(changedFields).forEach(([field, value]) => { - setFieldValue(field, value, false); - }); + setValues({ ...values, ...changedFields }, false); }, - [setFieldValue], + [setValues, values], ); const updateField = useCallback( From f00a36a7fe8b3b9b5182809d2dcc1f0e0c77377a Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 13:13:27 +0300 Subject: [PATCH 06/39] 59876 fix(template): flush pending task patches on unmount and fix updateTask batching --- .../TaskForm/__tests__/useTaskForm.test.tsx | 88 ++++++++++++++++++- .../TemplateEdit/TaskForm/useTaskForm.tsx | 51 +++++++---- 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx index 46e637ad4..10a734e8d 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { FormikProvider, useFormik } from 'formik'; -import { ITemplateTask } from '../../../../types/template'; +import { ETaskPerformerType, ITemplateTask } from '../../../../types/template'; import { TaskFormPersistProvider, useTaskForm } from '../useTaskForm'; const makeTask = (overrides: Partial = {}): ITemplateTask => ({ @@ -130,4 +130,90 @@ describe('TaskFormPersistProvider', () => { }, }); }); + + it('applies consecutive updateTask calls without dropping earlier changes', async () => { + const patchTask = jest.fn(); + const task = makeTask({ rawPerformers: [], requireCompletionByAll: false }); + const newPerformers = [{ + apiName: 'performer-1', + type: ETaskPerformerType.User, + label: 'User 1', + sourceId: '1', + }]; + + function PerformerEditor() { + const { updateTask } = useTaskForm(); + + return ( + + ); + } + + const { getByRole } = render( + + + , + ); + + await flushPersist(); + patchTask.mockClear(); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + await flushPersist(); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { + rawPerformers: newPerformers, + requireCompletionByAll: true, + }, + }); + }); + + it('persists pending changes when unmounting before the debounced effect runs', () => { + const patchTask = jest.fn(); + const task = makeTask({ name: 'Original' }); + + function NameEditor() { + const { updateField } = useTaskForm(); + + return ( + + ); + } + + const { getByRole, unmount } = render( + + + , + ); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + + unmount(); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { name: 'Updated' }, + }); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index 4d541ca90..2d00228f1 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -18,6 +18,28 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm const previousValuesRef = useRef(values); const externalTaskRef = useRef(task); const skipNextPersistRef = useRef(false); + const valuesRef = useRef(values); + const taskRef = useRef(task); + const patchTaskRef = useRef(patchTask); + + valuesRef.current = values; + taskRef.current = task; + patchTaskRef.current = patchTask; + + const flushPersist = useCallback(() => { + const currentValues = valuesRef.current; + + if (previousValuesRef.current === currentValues) { + return; + } + + const changedFields = getChangedFields(previousValuesRef.current, currentValues); + previousValuesRef.current = currentValues; + + if (Object.keys(changedFields).length > 0) { + patchTaskRef.current({ taskUUID: taskRef.current.uuid, changedFields }); + } + }, []); useEffect(() => { let timeoutId: number | undefined; @@ -29,26 +51,19 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm skipNextPersistRef.current = false; previousValuesRef.current = values; } else if (previousValuesRef.current !== values) { - timeoutId = window.setTimeout(() => { - if (previousValuesRef.current === values) { - return; - } - - const changedFields = getChangedFields(previousValuesRef.current, values); - previousValuesRef.current = values; - - if (Object.keys(changedFields).length > 0) { - patchTask({ taskUUID: task.uuid, changedFields }); - } - }, 0); + timeoutId = window.setTimeout(flushPersist, 0); } return () => { if (timeoutId !== undefined) { window.clearTimeout(timeoutId); + + if (taskRef.current.uuid === task.uuid) { + flushPersist(); + } } }; - }, [values, task, patchTask]); + }, [values, task, flushPersist]); return children as React.ReactElement; } @@ -67,12 +82,18 @@ function getChangedFields(previous: ITemplateTask, next: ITemplateTask): Partial export function useTaskForm() { const { values, setFieldValue, setValues } = useFormikContext(); + const valuesRef = useRef(values); + + valuesRef.current = values; const updateTask = useCallback( (changedFields: Partial) => { - setValues({ ...values, ...changedFields }, false); + const nextValues = { ...valuesRef.current, ...changedFields }; + + valuesRef.current = nextValues; + setValues(nextValues, false); }, - [setValues, values], + [setValues], ); const updateField = useCallback( From 2d2c587e5b4986d74a292ef2c8e268b971b3f9cf Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 13:20:06 +0300 Subject: [PATCH 07/39] 59876 fix(template): flush pending edits to previous task on early task switch --- .../TaskForm/__tests__/useTaskForm.test.tsx | 41 +++++++++++++++++++ .../TemplateEdit/TaskForm/useTaskForm.tsx | 32 +++++++-------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx index 10a734e8d..9175c1468 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -181,6 +181,47 @@ describe('TaskFormPersistProvider', () => { }); }); + it('persists pending changes when switching tasks before the debounced effect runs', () => { + const patchTask = jest.fn(); + const task1 = makeTask({ uuid: 'uuid-1', name: 'Task 1' }); + const task2 = makeTask({ uuid: 'uuid-2', name: 'Task 2' }); + + function NameEditor() { + const { updateField } = useTaskForm(); + + return ( + + ); + } + + const { getByRole, rerender } = render( + + + , + ); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + + rerender( + + + , + ); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { name: 'Updated Task 1' }, + }); + }); + it('persists pending changes when unmounting before the debounced effect runs', () => { const patchTask = jest.fn(); const task = makeTask({ name: 'Original' }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index 2d00228f1..ad150518e 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -19,16 +19,12 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm const externalTaskRef = useRef(task); const skipNextPersistRef = useRef(false); const valuesRef = useRef(values); - const taskRef = useRef(task); const patchTaskRef = useRef(patchTask); valuesRef.current = values; - taskRef.current = task; patchTaskRef.current = patchTask; - const flushPersist = useCallback(() => { - const currentValues = valuesRef.current; - + const flushPersistForTask = useCallback((taskUuid: string, currentValues: ITemplateTask) => { if (previousValuesRef.current === currentValues) { return; } @@ -37,7 +33,7 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm previousValuesRef.current = currentValues; if (Object.keys(changedFields).length > 0) { - patchTaskRef.current({ taskUUID: taskRef.current.uuid, changedFields }); + patchTaskRef.current({ taskUUID: taskUuid, changedFields }); } }, []); @@ -51,19 +47,23 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm skipNextPersistRef.current = false; previousValuesRef.current = values; } else if (previousValuesRef.current !== values) { - timeoutId = window.setTimeout(flushPersist, 0); - } + const capturedValues = values; + const capturedTaskUuid = task.uuid; - return () => { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + flushPersistForTask(capturedTaskUuid, valuesRef.current); + }, 0); - if (taskRef.current.uuid === task.uuid) { - flushPersist(); + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + flushPersistForTask(capturedTaskUuid, capturedValues); } - } - }; - }, [values, task, flushPersist]); + }; + } + + return undefined; + }, [values, task, flushPersistForTask]); return children as React.ReactElement; } From c672017077b8094e90e4d1030cdba1fec6e7604e Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 13:25:59 +0300 Subject: [PATCH 08/39] 59876 fix(template): detect task switch by uuid instead of object reference --- .../TaskForm/__tests__/useTaskForm.test.tsx | 45 +++++++++++++++++++ .../TemplateEdit/TaskForm/useTaskForm.tsx | 6 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx index 9175c1468..d4acb54dc 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -222,6 +222,51 @@ describe('TaskFormPersistProvider', () => { }); }); + it('persists user edits when the store passes a fresh object for the same task', async () => { + const patchTask = jest.fn(); + const task = makeTask({ name: 'Original' }); + + function NameEditor() { + const { updateField } = useTaskForm(); + + return ( + + ); + } + + const { getByRole, rerender } = render( + + + , + ); + + await flushPersist(); + patchTask.mockClear(); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + + rerender( + + + , + ); + + await flushPersist(); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { name: 'Updated' }, + }); + }); + it('persists pending changes when unmounting before the debounced effect runs', () => { const patchTask = jest.fn(); const task = makeTask({ name: 'Original' }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index ad150518e..7e445cebd 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -16,7 +16,7 @@ interface ITaskFormPersistProviderProps { export function TaskFormPersistProvider({ patchTask, task, children }: ITaskFormPersistProviderProps) { const { values } = useFormikContext(); const previousValuesRef = useRef(values); - const externalTaskRef = useRef(task); + const externalTaskUuidRef = useRef(task.uuid); const skipNextPersistRef = useRef(false); const valuesRef = useRef(values); const patchTaskRef = useRef(patchTask); @@ -40,8 +40,8 @@ export function TaskFormPersistProvider({ patchTask, task, children }: ITaskForm useEffect(() => { let timeoutId: number | undefined; - if (externalTaskRef.current !== task) { - externalTaskRef.current = task; + if (externalTaskUuidRef.current !== task.uuid) { + externalTaskUuidRef.current = task.uuid; skipNextPersistRef.current = true; } else if (skipNextPersistRef.current) { skipNextPersistRef.current = false; From 7c5794878773cb4dbe72e977b594905beb842971 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 13:33:07 +0300 Subject: [PATCH 09/39] 59876 fix(template): fix task form stale field updates --- .../TaskForm/__tests__/useTaskForm.test.tsx | 50 +++++++++++++++++++ .../TemplateEdit/TaskForm/useTaskForm.tsx | 1 + 2 files changed, 51 insertions(+) diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx index d4acb54dc..83b529fcc 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -131,6 +131,56 @@ describe('TaskFormPersistProvider', () => { }); }); + it('applies updateField changes when followed synchronously by updateTask', async () => { + const patchTask = jest.fn(); + const task = makeTask({ description: 'old', rawPerformers: [] }); + const newPerformers = [{ + apiName: 'performer-1', + type: ETaskPerformerType.User, + label: 'User 1', + sourceId: '1', + }]; + + function MixedEditor() { + const { updateField, updateTask } = useTaskForm(); + + return ( + + ); + } + + const { getByRole } = render( + + + , + ); + + await flushPersist(); + patchTask.mockClear(); + + act(() => { + getByRole('button', { name: 'edit' }).click(); + }); + await flushPersist(); + + expect(patchTask).toHaveBeenCalledTimes(1); + expect(patchTask).toHaveBeenCalledWith({ + taskUUID: 'uuid-1', + changedFields: { + description: 'new description', + rawPerformers: newPerformers, + }, + }); + }); + it('applies consecutive updateTask calls without dropping earlier changes', async () => { const patchTask = jest.fn(); const task = makeTask({ rawPerformers: [], requireCompletionByAll: false }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index 7e445cebd..e20c7c2b2 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -98,6 +98,7 @@ export function useTaskForm() { const updateField = useCallback( (field: K) => (value: ITemplateTask[K]) => { + valuesRef.current = { ...valuesRef.current, [field]: value }; setFieldValue(field, value, false); }, [setFieldValue], From f645756ba0893179131e02b61919700197dfe694 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 16:43:19 +0300 Subject: [PATCH 10/39] 59876 refactor(template): migrate Edit Template form to Formik --- .../KickoffRedux/KickoffRedux.tsx | 26 +- .../KickoffShareForm/KickoffShareForm.tsx | 13 +- .../TemplateEdit/KickoffRedux/container.ts | 7 +- .../TemplateEdit/TaskForm/TaskForm.tsx | 57 +-- .../__tests__/TaskPerformers.test.tsx | 362 ++++++++++------ .../TaskForm/__tests__/useTaskForm.test.tsx | 407 ++++++------------ .../TemplateEdit/TaskForm/container.ts | 6 +- .../TemplateEdit/TaskForm/useTaskForm.tsx | 123 ++---- .../TemplateControlls/TemplateControlls.tsx | 27 +- .../TemplateControlls/container.ts | 21 +- .../components/TemplateEdit/TemplateEdit.tsx | 98 ++--- .../TemplateSettings/TemplateSettings.tsx | 41 +- .../TemplateEdit/useTemplateForm.tsx | 214 +++++++++ 13 files changed, 705 insertions(+), 697 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm.tsx diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx index 4232bdfbf..1413dd6a1 100644 --- a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx +++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { IntlShape } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { getEmptyField } from './utils/getEmptyField'; import { KickoffShareForm } from './KickoffShareForm'; @@ -9,48 +8,39 @@ import { isKickoffCleared } from './utils/isKickoffCleared'; import { KickoffMenu } from './KickoffMenu'; import { IntlMessages } from '../../IntlMessages'; import { EMoveDirections, EInputNameBackgroundColor } from '../../../types/workflow'; -import { EExtraFieldMode, EExtraFieldType, IKickoff, IExtraField, ITemplate, ETemplateParts } from '../../../types/template'; +import { EExtraFieldMode, EExtraFieldType, IKickoff, IExtraField, ETemplateParts } from '../../../types/template'; import { isArrayWithItems } from '../../../utils/helpers'; import { getNormalizeFieldsOrders, moveWorkflowField } from '../../../utils/workflows'; import { ExtraFieldsMap } from '../ExtraFields/utils/ExtraFieldsMap'; import { ExtraFieldIcon } from '../ExtraFields/utils/ExtraFieldIcon'; import { ExtraFieldIntl } from '../ExtraFields'; import { getEditedFields } from '../ExtraFields/utils/getEditedFields'; -import { ETemplateStatus } from '../../../types/redux'; import { ExtraFieldsLabels } from '../ExtraFields/utils/ExtraFieldsLabels'; import { getEmptyKickoff } from '../../../utils/template'; import { useHashLink } from '../../../hooks/useHashLink'; import { useWorkflowNameVariables } from '../TaskForm/utils/getTaskVariables'; import styles from './KickoffRedux.css'; -import { patchTemplate } from '../../../redux/actions'; import { InputWithVariables } from '../InputWithVariables'; import { useDatasetOptions } from '../ExtraFields/utils/useDatasetOptions'; +import { useTemplateField } from '../useTemplateForm'; export interface IKickoffReduxProps { - template: ITemplate; intl: IntlShape; accountId: number; - templateStatus: ETemplateStatus; - setKickoff(value: IKickoff): void; } export function KickoffRedux({ - template: { kickoff, wfNameTemplate }, intl: { formatMessage }, - setKickoff, accountId, }: IKickoffReduxProps) { - const dispatch = useDispatch(); + const { values, setFieldValue } = useTemplateField(); + const { kickoff, wfNameTemplate } = values; const [isOpen, setIsOpen] = React.useState(false); const containerRef = React.useRef(null); const variables = useWorkflowNameVariables(kickoff); const datasetOptions = useDatasetOptions(kickoff.fields); - const editTemplate = (templateFields: Partial) => { - dispatch(patchTemplate({ changedFields: templateFields })); - }; - useHashLink([ { element: containerRef, @@ -70,12 +60,12 @@ export function KickoffRedux({ }; const handleChangeKickoff = (newKickoff: IKickoff) => { - setKickoff(newKickoff); + setFieldValue('kickoff', newKickoff, false); }; const handleClearKickoff = () => { const newKickoff = { ...getEmptyKickoff() }; - setKickoff(newKickoff); + handleChangeKickoff(newKickoff); }; const handleCreateField = (type: EExtraFieldType) => { @@ -125,9 +115,7 @@ export function KickoffRedux({ templateVariables={variables} value={wfNameTemplate || ''} onChange={(value: string) => { - editTemplate({ - wfNameTemplate: value, - }); + setFieldValue('wfNameTemplate', value, false); return Promise.resolve(value); }} diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx index f4ca2db94..0f57f335f 100644 --- a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx +++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx @@ -4,12 +4,10 @@ import Switch from 'rc-switch'; import * as React from 'react'; import { useIntl } from 'react-intl'; import classnames from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { default as TextareaAutosize } from 'react-textarea-autosize'; import { useMemo, useState } from 'react'; -import { patchTemplate } from '../../../../redux/actions'; -import { getTemplateData } from '../../../../redux/selectors/template'; import { getIsUserSubsribed, getSubscriptionPlan } from '../../../../redux/selectors/user'; import { copyToClipboard } from '../../../../utils/helpers'; import { NotificationManager } from '../../../UI/Notifications'; @@ -21,6 +19,7 @@ import { trackShareKickoffForm } from '../../../../utils/analytics'; import { TPublicFormType } from '../../../../types/publicForms'; import { generateEmbedCode } from './utils/generateEmbedCode'; import { ESubscriptionPlan } from '../../../../types/account'; +import { useTemplateField } from '../../useTemplateForm'; import styles from './KickoffShareForm.css'; @@ -29,12 +28,12 @@ interface IKickoffShareFormProps { } export function KickoffShareForm({ className }: IKickoffShareFormProps) { - const dispatch = useDispatch(); const { formatMessage } = useIntl(); + const { values, setFieldValue } = useTemplateField(); const isSubscribed = useSelector(getIsUserSubsribed); const subcriptionPlan = useSelector(getSubscriptionPlan); const accessSharedForm = isSubscribed || subcriptionPlan === ESubscriptionPlan.Free; - const { publicUrl, isPublic, publicSuccessUrl, embedUrl, isEmbedded } = useSelector(getTemplateData); + const { publicUrl, isPublic, publicSuccessUrl, embedUrl, isEmbedded } = values; const TABS: { id: TPublicFormType; label: string }[] = useMemo( () => [ @@ -62,7 +61,9 @@ export function KickoffShareForm({ className }: IKickoffShareFormProps) { }, [embedUrl]); const editTemplate = (templateFields: Partial) => { - dispatch(patchTemplate({ changedFields: templateFields })); + (Object.keys(templateFields) as (keyof ITemplate)[]).forEach((key) => { + setFieldValue(key as string, templateFields[key], false); + }); }; useDidUpdateEffect(() => { diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/container.ts b/frontend/src/public/components/TemplateEdit/KickoffRedux/container.ts index 19b866936..a4236d3bb 100644 --- a/frontend/src/public/components/TemplateEdit/KickoffRedux/container.ts +++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/container.ts @@ -5,17 +5,14 @@ import { IApplicationState } from '../../../types/redux'; import { IKickoffReduxProps, KickoffRedux } from './KickoffRedux'; -type TStoreProps = Pick; +type TStoreProps = Pick; export function mapStateToProps({ - template: { data: template, status }, authUser: { account }, }: IApplicationState): TStoreProps { return { - template, - templateStatus: status, accountId: account.id || -1, }; } -export const KickoffReduxContainer = injectIntl(connect(mapStateToProps)(KickoffRedux)); +export const KickoffReduxContainer = injectIntl(connect(mapStateToProps)(KickoffRedux)); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx index 1d04d50b1..33eeeb19d 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -1,16 +1,14 @@ import * as React from 'react'; import { useMemo, useRef } from 'react'; -import { FormikProvider, useFormik } from 'formik'; import { TUserListItem } from '../../../types/user'; import { IKickoff, ITemplateTask } from '../../../types/template'; import { TTaskVariable, TTaskFormPart } from '../types'; -import { TPatchTaskPayload } from '../../../redux/actions'; import { getSystemVariables } from './utils/getTaskVariables'; import { TaskFormHeader } from './TaskFormHeader'; import { TaskFormSections } from './TaskFormSections'; -import { TaskFormPersistProvider } from './useTaskForm'; +import { TaskFormScopeProvider } from '../useTemplateForm'; import styles from '../TemplateEdit.css'; @@ -25,7 +23,6 @@ export interface ITaskFormProps { isTeamInvitesModalOpen: boolean; tasks: ITemplateTask[]; kickoff: IKickoff; - patchTask(args: TPatchTaskPayload): void; } export function TaskForm({ @@ -39,48 +36,40 @@ export function TaskForm({ isTeamInvitesModalOpen, tasks, kickoff, - patchTask, templateId, }: ITaskFormProps & { templateId: number | undefined }) { if (!task) return null; const wrapperRef = useRef(null); - const formik = useFormik({ - initialValues: task, - enableReinitialize: true, - onSubmit: () => undefined, - }); const listSystemVariables = useMemo(() => [ ...getSystemVariables(), ...listVariables, ], [listVariables]); return ( - - -
-
- + +
+
+ - -
+
- - +
+ ); } diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx index 09125fe75..de1b64502 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx @@ -5,9 +5,9 @@ import { FormikProvider, useFormik } from 'formik'; import { intlMock } from '../../../../__stubs__/intlMock'; import { Checkbox } from '../../../UI/Fields/Checkbox'; -import { ITemplateTask } from '../../../../types/template'; +import { ITemplate, ITemplateTask } from '../../../../types/template'; import { TaskPerformers } from '../TaskPerformers'; -import { TaskFormPersistProvider } from '../useTaskForm'; +import { TaskFormScopeProvider, TemplateFieldContext } from '../../useTemplateForm'; jest.mock('../../../UI/Fields/Checkbox', () => ({ Checkbox: jest.fn(() => null), @@ -42,8 +42,6 @@ describe('TaskPerformers', () => { const t = (id: string) => intlMock.formatMessage({ id }); const SKIP_LABEL = t('templates.task-skip-for-starter'); - const mockPatchTask = jest.fn(); - const makeTask = (overrides: Partial = {}): ITemplateTask => ({ apiName: 'task-1', number: 1, @@ -63,106 +61,78 @@ describe('TaskPerformers', () => { ...overrides, }); + const makeTemplate = (tasks: ITemplateTask[]): ITemplate => + ({ + id: 1, + name: 'Template', + description: '', + isActive: false, + finalizable: false, + completionNotification: false, + reminderNotification: false, + dateUpdated: null, + updatedBy: null, + owners: [], + kickoff: { description: '', fields: [] } as any, + tasks, + isPublic: false, + publicUrl: null, + publicSuccessUrl: null, + isEmbedded: false, + embedUrl: null, + wfNameTemplate: null, + tasksCount: tasks.length, + performersCount: 0, + }) as ITemplate; + const flushPersist = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - const createTaskFormWrapper = (task: ITemplateTask, patchTask = mockPatchTask) => { + const createTaskFormWrapper = (task: ITemplateTask, setFieldValue = jest.fn()) => { function TaskFormTestWrapper({ children }: { children: React.ReactNode }) { - const formik = useFormik({ - initialValues: task, + const formik = useFormik({ + initialValues: makeTemplate([task]), enableReinitialize: true, onSubmit: () => undefined, }); - return React.createElement( - FormikProvider, - { value: formik }, - React.createElement( - TaskFormPersistProvider, - { patchTask, task, children }, - ), + return ( + + + {children} + + ); } return TaskFormTestWrapper; }; - const renderComponent = (taskOverrides: Partial = {}) => { - const task = makeTask(taskOverrides); - const Wrapper = createTaskFormWrapper(task); - - return render( - React.createElement( - Wrapper, - null, - React.createElement(TaskPerformers, { - tasks: [task], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - }), - ), - ); - }; - - const getSkipCheckboxCall = () => { - const calls = (Checkbox as jest.Mock).mock.calls; - return calls.find( - (c: any[]) => c[0].checkboxId === 'skipForStarter-task-1', - ); - }; - - const getCompleteByAllCall = () => { - const calls = (Checkbox as jest.Mock).mock.calls; - return calls.find( - (c: any[]) => c[0].checkboxId === 'completeByAll-task-1', - ); - }; + beforeEach(() => { + jest.clearAllMocks(); + }); - const renderTwoTasks = () => { - const task1 = makeTask({ apiName: 'task-1' }); - const task2 = makeTask({ apiName: 'task-2' }); - const mockPatchTask1 = jest.fn(); - const mockPatchTask2 = jest.fn(); - const Wrapper1 = createTaskFormWrapper(task1, mockPatchTask1); - const Wrapper2 = createTaskFormWrapper(task2, mockPatchTask2); + describe('checkbox identifiers', () => { + it('generates unique checkboxId using task.apiName to prevent HTML id collisions', () => { + const task = makeTask({ apiName: 'custom-task-123' }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - render( - React.createElement('div', null, + render( React.createElement( - Wrapper1, + Wrapper, null, React.createElement(TaskPerformers, { - tasks: [task1, task2], + tasks: [task], users: [], variables: [], isTeamInvitesModalOpen: false, }), ), - React.createElement( - Wrapper2, - null, - React.createElement(TaskPerformers, { - tasks: [task1, task2], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - }), - ), - ), - ); - - return { mockPatchTask1, mockPatchTask2 }; - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('checkbox identifiers', () => { - it('generates unique checkboxId using task.apiName to prevent HTML id collisions', () => { - renderComponent({ apiName: 'custom-task-123' }); + ); const calls = (Checkbox as jest.Mock).mock.calls; const completeByAllCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('completeByAll-')); @@ -175,21 +145,23 @@ describe('TaskPerformers', () => { expect(skipForStarterCall![0].checkboxId).toBe('skipForStarter-custom-task-123'); }); - it('two tasks produce different checkboxIds (no DOM id collisions)', () => { - renderTwoTasks(); - - const calls = (Checkbox as jest.Mock).mock.calls; - const checkboxIds = calls.map((c: any[]) => c[0].checkboxId); - - const completeByAllIds = checkboxIds.filter((id: string) => id?.startsWith('completeByAll-')); - const skipForStarterIds = checkboxIds.filter((id: string) => id?.startsWith('skipForStarter-')); - - expect([...new Set(completeByAllIds)]).toEqual(['completeByAll-task-1', 'completeByAll-task-2']); - expect([...new Set(skipForStarterIds)]).toEqual(['skipForStarter-task-1', 'skipForStarter-task-2']); - }); - it('uses checkboxId prop (not id) to ensure proper label-input binding', () => { - renderComponent(); + const task = makeTask(); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); + + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); const calls = (Checkbox as jest.Mock).mock.calls; @@ -202,103 +174,241 @@ describe('TaskPerformers', () => { describe('requireCompletionByAll checkbox', () => { it('passes title with correct localization text', () => { - renderComponent(); + const task = makeTask(); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); + + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); - const call = getCompleteByAllCall(); + const calls = (Checkbox as jest.Mock).mock.calls; + const call = calls.find((c: any[]) => c[0].checkboxId === 'completeByAll-task-1'); expect(call).toBeDefined(); expect(call![0].title).toBe(t('templates.task-require-completion-by-all')); }); it('passes checked=true when requireCompletionByAll=true', () => { - renderComponent({ requireCompletionByAll: true }); + const task = makeTask({ requireCompletionByAll: true }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - const call = getCompleteByAllCall(); + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); + + const calls = (Checkbox as jest.Mock).mock.calls; + const call = calls.find((c: any[]) => c[0].checkboxId === 'completeByAll-task-1'); expect(call![0].checked).toBe(true); }); it('passes checked=false when requireCompletionByAll=false', () => { - renderComponent({ requireCompletionByAll: false }); + const task = makeTask({ requireCompletionByAll: false }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - const call = getCompleteByAllCall(); + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); + + const calls = (Checkbox as jest.Mock).mock.calls; + const call = calls.find((c: any[]) => c[0].checkboxId === 'completeByAll-task-1'); expect(call![0].checked).toBe(false); }); - it('calls patchTask with requireCompletionByAll on onChange', async () => { - renderComponent({ requireCompletionByAll: false }); + it('calls setFieldValue with tasks.0.requireCompletionByAll on onChange', async () => { + const task = makeTask({ requireCompletionByAll: false }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - const call = getCompleteByAllCall(); + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); + + const calls = (Checkbox as jest.Mock).mock.calls; + const call = calls.find((c: any[]) => c[0].checkboxId === 'completeByAll-task-1'); const onChangeFn = call![0].onChange; + act(() => { onChangeFn({ currentTarget: { checked: true } }); }); await flushPersist(); - expect(mockPatchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { requireCompletionByAll: true }, - }); + expect(setFieldValue).toHaveBeenCalledWith('tasks.0', { ...task, requireCompletionByAll: true }, false); }); }); describe('skipForStarter checkbox', () => { it('passes title with correct localization text', () => { - renderComponent(); + const task = makeTask(); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); + + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); - const skipCall = getSkipCheckboxCall(); + const calls = (Checkbox as jest.Mock).mock.calls; + const skipCall = calls.find((c: any[]) => c[0].checkboxId === 'skipForStarter-task-1'); expect(skipCall).toBeDefined(); expect(skipCall![0].title).toBe(SKIP_LABEL); }); it('passes checked=true when skipForStarter=true', () => { - renderComponent({ skipForStarter: true }); + const task = makeTask({ skipForStarter: true }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); + + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); - const skipCall = getSkipCheckboxCall(); + const calls = (Checkbox as jest.Mock).mock.calls; + const skipCall = calls.find((c: any[]) => c[0].checkboxId === 'skipForStarter-task-1'); expect(skipCall![0].checked).toBe(true); }); it('passes checked=false when skipForStarter=false', () => { - renderComponent({ skipForStarter: false }); + const task = makeTask({ skipForStarter: false }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - const skipCall = getSkipCheckboxCall(); + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); + + const calls = (Checkbox as jest.Mock).mock.calls; + const skipCall = calls.find((c: any[]) => c[0].checkboxId === 'skipForStarter-task-1'); expect(skipCall![0].checked).toBe(false); }); - it('calls patchTask with true on onChange', async () => { - renderComponent({ skipForStarter: false }); + it('calls setFieldValue with true on onChange', async () => { + const task = makeTask({ skipForStarter: false }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); + + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); - const skipCall = getSkipCheckboxCall(); + const calls = (Checkbox as jest.Mock).mock.calls; + const skipCall = calls.find((c: any[]) => c[0].checkboxId === 'skipForStarter-task-1'); const onChangeFn = skipCall![0].onChange; + act(() => { onChangeFn({ currentTarget: { checked: true } }); }); await flushPersist(); - expect(mockPatchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { skipForStarter: true }, - }); + expect(setFieldValue).toHaveBeenCalledWith('tasks.0', { ...task, skipForStarter: true }, false); }); - it('calls patchTask with false on checkbox uncheck', async () => { - renderComponent({ skipForStarter: true }); + it('calls setFieldValue with false on checkbox uncheck', async () => { + const task = makeTask({ skipForStarter: true }); + const setFieldValue = jest.fn(); + const Wrapper = createTaskFormWrapper(task, setFieldValue); - const skipCall = getSkipCheckboxCall(); + render( + React.createElement( + Wrapper, + null, + React.createElement(TaskPerformers, { + tasks: [task], + users: [], + variables: [], + isTeamInvitesModalOpen: false, + }), + ), + ); + + const calls = (Checkbox as jest.Mock).mock.calls; + const skipCall = calls.find((c: any[]) => c[0].checkboxId === 'skipForStarter-task-1'); const onChangeFn = skipCall![0].onChange; + act(() => { onChangeFn({ currentTarget: { checked: false } }); }); await flushPersist(); - expect(mockPatchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { skipForStarter: false }, - }); + expect(setFieldValue).toHaveBeenCalledWith('tasks.0', { ...task, skipForStarter: false }, false); }); }); }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx index 83b529fcc..7af82cd13 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -1,10 +1,10 @@ /// import * as React from 'react'; import { act, render } from '@testing-library/react'; -import { FormikProvider, useFormik } from 'formik'; -import { ETaskPerformerType, ITemplateTask } from '../../../../types/template'; -import { TaskFormPersistProvider, useTaskForm } from '../useTaskForm'; +import { ETaskPerformerType, ITemplate, ITemplateTask } from '../../../../types/template'; +import { TaskFormScopeProvider, TemplateFieldContext } from '../../useTemplateForm'; +import { useTaskForm } from '../useTaskForm'; const makeTask = (overrides: Partial = {}): ITemplateTask => ({ apiName: 'task-1', @@ -25,331 +25,166 @@ const makeTask = (overrides: Partial = {}): ITemplateTask => ({ ...overrides, }); -const flushPersist = () => act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); -}); - -interface IHarnessProps { - task: ITemplateTask; - patchTask: jest.Mock; - children: React.ReactNode; +const makeTemplate = (tasks: ITemplateTask[]): ITemplate => + ({ + id: 1, + name: 'Template', + description: '', + isActive: false, + finalizable: false, + completionNotification: false, + reminderNotification: false, + dateUpdated: null, + updatedBy: null, + owners: [], + kickoff: { description: '', fields: [] } as any, + tasks, + isPublic: false, + publicUrl: null, + publicSuccessUrl: null, + isEmbedded: false, + embedUrl: null, + wfNameTemplate: null, + tasksCount: tasks.length, + performersCount: 0, + }) as ITemplate; + +interface IFormikBag { + values: ITemplate; + setFieldValue: jest.Mock; } -function TaskFormHarness({ task, patchTask, children }: IHarnessProps) { - const formik = useFormik({ - initialValues: task, - enableReinitialize: true, - onSubmit: () => undefined, - }); - +function TaskFormHarness({ + bag, + taskUuid, + children, +}: { + bag: IFormikBag; + taskUuid: string; + children: React.ReactNode; +}) { return ( - - - {children} - - + + {children} + ); } -describe('TaskFormPersistProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('does not patch when the external task prop changes', async () => { - const patchTask = jest.fn(); - const task1 = makeTask({ uuid: 'uuid-1', name: 'Task 1' }); - const task2 = makeTask({ uuid: 'uuid-2', name: 'Task 2' }); +describe('useTaskForm', () => { + it('throws when used outside ', () => { + const Spy: React.FC = () => { + useTaskForm(); + return null; + }; - const { rerender } = render( - - - , - ); + const bag: IFormikBag = { + values: makeTemplate([makeTask()]), + setFieldValue: jest.fn(), + }; - await flushPersist(); - patchTask.mockClear(); + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); - rerender( - - - , - ); - - await flushPersist(); - - expect(patchTask).not.toHaveBeenCalled(); - }); - - it('batches rapid field updates into a single patch', async () => { - const patchTask = jest.fn(); - const task = makeTask({ description: 'old', checklists: [] }); - - function DescriptionEditorSimulator() { - const { updateField } = useTaskForm(); - - return ( - - ); - } + + , + ), + ).toThrow(/TaskFormScopeProvider/); - const { getByRole } = render( - - - , - ); - - await flushPersist(); - patchTask.mockClear(); - - act(() => { - getByRole('button', { name: 'edit' }).click(); - }); - await flushPersist(); - - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { - checklists: [{ - apiName: 'checklist-1', - selections: [{ apiName: 'item-1', value: 'Item' }], - }], - description: 'new description', - }, - }); + spy.mockRestore(); }); - it('applies updateField changes when followed synchronously by updateTask', async () => { - const patchTask = jest.fn(); - const task = makeTask({ description: 'old', rawPerformers: [] }); - const newPerformers = [{ - apiName: 'performer-1', - type: ETaskPerformerType.User, - label: 'User 1', - sourceId: '1', - }]; - - function MixedEditor() { - const { updateField, updateTask } = useTaskForm(); + it('binds updateField to tasks[index]. via setFieldValue', () => { + const setFieldValue = jest.fn(); + const task = makeTask({ uuid: 'uuid-1', name: 'Old' }); + const bag: IFormikBag = { values: makeTemplate([task]), setFieldValue }; - return ( - - ); - } + let form: ReturnType | null = null; + const Spy: React.FC = () => { + form = useTaskForm(); + return null; + }; - const { getByRole } = render( - - + render( + + , ); - await flushPersist(); - patchTask.mockClear(); - act(() => { - getByRole('button', { name: 'edit' }).click(); + form!.updateField('name')('New name'); }); - await flushPersist(); - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { - description: 'new description', - rawPerformers: newPerformers, - }, - }); + expect(setFieldValue).toHaveBeenCalledWith('tasks.0.name', 'New name', false); }); - it('applies consecutive updateTask calls without dropping earlier changes', async () => { - const patchTask = jest.fn(); - const task = makeTask({ rawPerformers: [], requireCompletionByAll: false }); - const newPerformers = [{ - apiName: 'performer-1', - type: ETaskPerformerType.User, - label: 'User 1', - sourceId: '1', - }]; + it('binds updateTask to tasks[index] via setFieldValue, merging onto the current task', () => { + const setFieldValue = jest.fn(); + const task = makeTask({ uuid: 'uuid-1', description: 'old', rawPerformers: [] }); + const bag: IFormikBag = { values: makeTemplate([task]), setFieldValue }; - function PerformerEditor() { - const { updateTask } = useTaskForm(); + let form: ReturnType | null = null; + const Spy: React.FC = () => { + form = useTaskForm(); + return null; + }; - return ( - - ); - } - - const { getByRole } = render( - - + render( + + , ); - await flushPersist(); - patchTask.mockClear(); - - act(() => { - getByRole('button', { name: 'edit' }).click(); - }); - await flushPersist(); - - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { - rawPerformers: newPerformers, - requireCompletionByAll: true, + const newPerformers = [ + { + apiName: 'performer-1', + type: ETaskPerformerType.User, + label: 'User 1', + sourceId: '1', }, - }); - }); - - it('persists pending changes when switching tasks before the debounced effect runs', () => { - const patchTask = jest.fn(); - const task1 = makeTask({ uuid: 'uuid-1', name: 'Task 1' }); - const task2 = makeTask({ uuid: 'uuid-2', name: 'Task 2' }); - - function NameEditor() { - const { updateField } = useTaskForm(); - - return ( - - ); - } - - const { getByRole, rerender } = render( - - - , - ); + ]; act(() => { - getByRole('button', { name: 'edit' }).click(); + form!.updateTask({ rawPerformers: newPerformers, requireCompletionByAll: true }); }); - rerender( - - - , + expect(setFieldValue).toHaveBeenCalledTimes(1); + expect(setFieldValue).toHaveBeenCalledWith( + 'tasks.0', + { ...task, rawPerformers: newPerformers, requireCompletionByAll: true }, + false, ); - - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { name: 'Updated Task 1' }, - }); }); - it('persists user edits when the store passes a fresh object for the same task', async () => { - const patchTask = jest.fn(); - const task = makeTask({ name: 'Original' }); - - function NameEditor() { - const { updateField } = useTaskForm(); - - return ( - - ); - } - - const { getByRole, rerender } = render( - - - , - ); - - await flushPersist(); - patchTask.mockClear(); - - act(() => { - getByRole('button', { name: 'edit' }).click(); - }); - - rerender( - - + it('targets the correct index when the scoped task is not the first one', () => { + const setFieldValue = jest.fn(); + const task1 = makeTask({ uuid: 'uuid-1', name: 'First' }); + const task2 = makeTask({ uuid: 'uuid-2', name: 'Second', number: 2 }); + const bag: IFormikBag = { values: makeTemplate([task1, task2]), setFieldValue }; + + let form: ReturnType | null = null; + const Spy: React.FC = () => { + form = useTaskForm(); + return null; + }; + + render( + + , ); - await flushPersist(); - - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { name: 'Updated' }, - }); - }); - - it('persists pending changes when unmounting before the debounced effect runs', () => { - const patchTask = jest.fn(); - const task = makeTask({ name: 'Original' }); - - function NameEditor() { - const { updateField } = useTaskForm(); - - return ( - - ); - } - - const { getByRole, unmount } = render( - - - , - ); + expect(form!.task).toEqual(task2); act(() => { - getByRole('button', { name: 'edit' }).click(); + form!.updateField('name')('Updated second'); }); - unmount(); - - expect(patchTask).toHaveBeenCalledTimes(1); - expect(patchTask).toHaveBeenCalledWith({ - taskUUID: 'uuid-1', - changedFields: { name: 'Updated' }, - }); + expect(setFieldValue).toHaveBeenCalledWith('tasks.1.name', 'Updated second', false); }); }); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts index 199e0a4dc..0dee5cc58 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts +++ b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts @@ -3,14 +3,12 @@ import { connect } from 'react-redux'; import { TaskForm, ITaskFormProps } from './TaskForm'; import { IApplicationState } from '../../../types/redux'; import { getTaskVariables, getVariables } from './utils/getTaskVariables'; -import { patchTask } from '../../../redux/actions'; import { getIsUserSubsribed } from '../../../redux/selectors/user'; type TStoreProps = Pick< ITaskFormProps, 'listVariables' | 'templateVariables' | 'isSubscribed' | 'accountId' | 'isTeamInvitesModalOpen' | 'kickoff' | 'tasks' > & { templateId: number | undefined }; -type TDispatchProps = Pick; type TOwnProps = Pick; const mapStateToProps = (state: IApplicationState, { task }: TOwnProps): TStoreProps => { @@ -35,6 +33,4 @@ const mapStateToProps = (state: IApplicationState, { task }: TOwnProps): TStoreP }; }; -const mapDispatchToProps: TDispatchProps = { patchTask }; - -export const WorkflowTaskFormContainer = connect(mapStateToProps, mapDispatchToProps)(TaskForm); +export const WorkflowTaskFormContainer = connect(mapStateToProps)(TaskForm); diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx index e20c7c2b2..2f4034b35 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -1,111 +1,50 @@ -import * as React from 'react'; -import { useCallback, useEffect, useRef } from 'react'; -import { useFormikContext } from 'formik'; +import { useCallback, useRef } from 'react'; import { ITemplateTask } from '../../../types/template'; -import { TPatchTaskPayload } from '../../../redux/actions'; - -type TPatchTaskFn = (payload: TPatchTaskPayload) => void; - -interface ITaskFormPersistProviderProps { - patchTask: TPatchTaskFn; - task: ITemplateTask; - children: React.ReactNode; -} - -export function TaskFormPersistProvider({ patchTask, task, children }: ITaskFormPersistProviderProps) { - const { values } = useFormikContext(); - const previousValuesRef = useRef(values); - const externalTaskUuidRef = useRef(task.uuid); - const skipNextPersistRef = useRef(false); - const valuesRef = useRef(values); - const patchTaskRef = useRef(patchTask); - - valuesRef.current = values; - patchTaskRef.current = patchTask; - - const flushPersistForTask = useCallback((taskUuid: string, currentValues: ITemplateTask) => { - if (previousValuesRef.current === currentValues) { - return; - } - - const changedFields = getChangedFields(previousValuesRef.current, currentValues); - previousValuesRef.current = currentValues; - - if (Object.keys(changedFields).length > 0) { - patchTaskRef.current({ taskUUID: taskUuid, changedFields }); - } - }, []); - - useEffect(() => { - let timeoutId: number | undefined; - - if (externalTaskUuidRef.current !== task.uuid) { - externalTaskUuidRef.current = task.uuid; - skipNextPersistRef.current = true; - } else if (skipNextPersistRef.current) { - skipNextPersistRef.current = false; - previousValuesRef.current = values; - } else if (previousValuesRef.current !== values) { - const capturedValues = values; - const capturedTaskUuid = task.uuid; - - timeoutId = window.setTimeout(() => { - flushPersistForTask(capturedTaskUuid, valuesRef.current); - }, 0); - - return () => { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - flushPersistForTask(capturedTaskUuid, capturedValues); - } - }; - } - - return undefined; - }, [values, task, flushPersistForTask]); - - return children as React.ReactElement; -} - -function getChangedFields(previous: ITemplateTask, next: ITemplateTask): Partial { - const changedFields: Partial = {}; - - (Object.keys(next) as (keyof ITemplateTask)[]).forEach((key) => { - if (previous[key] !== next[key]) { - (changedFields[key] as ITemplateTask[keyof ITemplateTask]) = next[key]; - } - }); - - return changedFields; -} - +import { useTaskFormScope, useTemplateField } from '../useTemplateForm'; + +/** + * Task-level Formik binding backed by the root `ITemplate` Formik context. + * + * Returns the same `{ task, updateTask, updateField }` shape the section + * components already consume, so they don't need to change. `updateField` / + * `updateTask` write through the wrapped `setFieldValue('tasks[index]...', ...)` + * from `useTemplateField`, so the root `TemplateFormPersistProvider` saves them + * from the single centralized save point. + */ export function useTaskForm() { - const { values, setFieldValue, setValues } = useFormikContext(); - const valuesRef = useRef(values); + const { values, setFieldValue } = useTemplateField(); + const taskUuid = useTaskFormScope(); - valuesRef.current = values; + const index = values.tasks.findIndex((task) => task.uuid === taskUuid); + const task = values.tasks[index]; + const taskRef = useRef(task); + taskRef.current = task; const updateTask = useCallback( (changedFields: Partial) => { - const nextValues = { ...valuesRef.current, ...changedFields }; - - valuesRef.current = nextValues; - setValues(nextValues, false); + const nextTask = { ...taskRef.current, ...changedFields }; + taskRef.current = nextTask; + setFieldValue(`tasks.${index}`, nextTask, false); }, - [setValues], + [index, setFieldValue], ); const updateField = useCallback( (field: K) => (value: ITemplateTask[K]) => { - valuesRef.current = { ...valuesRef.current, [field]: value }; - setFieldValue(field, value, false); + const nextTask = { ...taskRef.current, [field]: value }; + taskRef.current = nextTask; + setFieldValue(`tasks.${index}.${String(field)}`, value, false); }, - [setFieldValue], + [index, setFieldValue], ); + if (!task) { + throw new Error(`Task with uuid "${taskUuid}" is not present in the template form state`); + } + return { - task: values, + task, updateTask, updateField, }; diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index 5fcb94e9f..b9cbc9023 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -3,8 +3,8 @@ import Switch from 'rc-switch'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { useIntl } from 'react-intl'; - import { useDispatch, useSelector } from 'react-redux'; + import { TemplateOwners } from '../TemplateOwners'; import { TemplateViewers } from '../TemplateViewers'; import { TemplateStarters } from '../TemplateStarters'; @@ -23,6 +23,7 @@ import { } from '../../../redux/actions'; import { getRunnableWorkflow, loadDatasetsMap } from '../utils/getRunnableWorkflow'; import { ETemplateStatus } from '../../../types/redux'; +import { getTemplateStatus } from '../../../redux/selectors/template'; import { IRunWorkflow } from '../../WorkflowEditPopup/types'; import { WarningPopup } from '../../UI/WarningPopup'; import { validateTemplate } from '../utils/validateTemplate'; @@ -36,13 +37,11 @@ import { useTemplateIntegrationsList } from '../../TemplateIntegrationsStats'; import { checkShowDraftTemplateWarning } from '../../Templates'; import styles from './TemplateControlls.css'; -import { getSubscriptionPlan } from '../../../redux/selectors/user'; +import { getSubscriptionPlan, getIsUserSubsribed } from '../../../redux/selectors/user'; import { ESubscriptionPlan } from '../../../types/account'; +import { useTemplateField } from '../useTemplateForm'; export interface ITemplateControllsProps { - template: ITemplate; - templateStatus: ETemplateStatus; - isSubscribed: boolean; cloneTemplate(payload: TCloneTemplatePayload): void; patchTemplate(payload: TPatchTemplatePayload): void; deleteTemplate(payload: TDeleteTemplatePayload): void; @@ -51,9 +50,6 @@ export interface ITemplateControllsProps { } export function TemplateControlls({ - template, - templateStatus, - isSubscribed, patchTemplate, cloneTemplate, deleteTemplate, @@ -63,6 +59,9 @@ export function TemplateControlls({ const intl = useIntl(); const { formatMessage } = intl; const dispatch = useDispatch(); + const { values: template, setFieldValue } = useTemplateField(); + const templateStatus = useSelector(getTemplateStatus); + const isSubscribed = useSelector(getIsUserSubsribed); const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; const accessConditions = isSubscribed || isFreePlan; @@ -272,7 +271,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...newTemplateOwners, ...viewers, ...starters] } }) + setFieldValue('owners', [...newTemplateOwners, ...viewers, ...starters], false) } /> @@ -283,7 +282,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...pureOwners, ...newViewers, ...starters] } }) + setFieldValue('owners', [...pureOwners, ...newViewers, ...starters], false) } /> @@ -294,7 +293,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...pureOwners, ...viewers, ...newStarters] } }) + setFieldValue('owners', [...pureOwners, ...viewers, ...newStarters], false) } /> @@ -353,7 +352,7 @@ export function TemplateControlls({ checked={isTemplateFinalizable} checkedChildren={null} unCheckedChildren={null} - onChange={(value) => patchTemplate({ changedFields: { finalizable: value } })} + onChange={(value) => setFieldValue('finalizable', value, false)} />
@@ -368,7 +367,7 @@ export function TemplateControlls({ checked={isCompletionNotification} checkedChildren={null} unCheckedChildren={null} - onChange={(value) => patchTemplate({ changedFields: { completionNotification: value } })} + onChange={(value) => setFieldValue('completionNotification', value, false)} />
@@ -383,7 +382,7 @@ export function TemplateControlls({ checked={isReminderNotification} checkedChildren={null} unCheckedChildren={null} - onChange={(value) => patchTemplate({ changedFields: { reminderNotification: value } })} + onChange={(value) => setFieldValue('reminderNotification', value, false)} />
diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/container.ts b/frontend/src/public/components/TemplateEdit/TemplateControlls/container.ts index 17de0205d..5a4114c5b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/container.ts +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/container.ts @@ -1,7 +1,5 @@ import { connect } from 'react-redux'; -import { IApplicationState } from '../../../types/redux'; - import { ITemplateControllsProps, TemplateControlls } from './TemplateControlls'; import { patchTemplate, @@ -9,20 +7,11 @@ import { deleteTemplate, openRunWorkflowModal, } from '../../../redux/actions'; -import { getIsUserSubsribed } from '../../../redux/selectors/user'; - -type TStoreProps = Pick; -type TDispatchProps = Pick; - -export function mapStateToProps(state: IApplicationState): TStoreProps { - const { - template: { data: template, status }, - } = state; - - const isSubscribed = getIsUserSubsribed(state); - return { template, templateStatus: status, isSubscribed }; -} +type TDispatchProps = Pick< + ITemplateControllsProps, + 'patchTemplate' | 'cloneTemplate' | 'deleteTemplate' | 'openRunWorkflowModal' +>; export const mapDispatchToProps: TDispatchProps = { patchTemplate, @@ -31,4 +20,4 @@ export const mapDispatchToProps: TDispatchProps = { openRunWorkflowModal, }; -export const TemplateControllsContainer = connect(mapStateToProps, mapDispatchToProps)(TemplateControlls); +export const TemplateControllsContainer = connect<{}, TDispatchProps>(null, mapDispatchToProps)(TemplateControlls); diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 520b2bf69..848d98879 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useIntl } from 'react-intl'; import { RouteComponentProps } from 'react-router-dom'; -import { debounce } from 'throttle-debounce'; import { IInfoWarningProps } from './InfoWarningsModal'; import { getClonedTask } from './utils/getClonedTask'; @@ -14,7 +13,7 @@ import { getVariables } from './TaskForm/utils/getTaskVariables'; import { TemplateIntegrations } from './Integrations'; import { ERoutes } from '../../constants/routes'; import { TUserListItem } from '../../types/user'; -import { cleanTemplateReferences, getEmptyKickoff, getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template'; +import { getEmptyKickoff, getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template'; import { checkSomeRouteIsActive, isCreateTemplate } from '../../utils/history'; import { KickoffReduxContainer } from './KickoffRedux'; import { moveTask } from '../../utils/workflows'; @@ -34,6 +33,7 @@ import { getUserFullName } from '../../utils/users'; import { getSubscriptionPlan } from '../../redux/selectors/user'; import { ESubscriptionPlan } from '../../types/account'; import { TemplateSettings } from './TemplateSettings'; +import { TemplateForm, useTemplateForm } from './useTemplateForm'; import styles from './TemplateEdit.css'; import { getEmptyConditions } from './TaskForm/Conditions/utils/getEmptyConditions'; @@ -83,11 +83,12 @@ export function TemplateEdit({ resetTemplateStore, saveTemplate, setTemplate, - setTemplateStatus, loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const { tasks, owners } = template; + const { owners } = template; + const { formik, setFieldValue, setValues, dirtyRef } = useTemplateForm(template); + const { tasks } = formik.values; const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; const accessConditions = isSubscribed || isFreePlan; @@ -253,38 +254,8 @@ export function TemplateEdit({ } as ITemplate; }; - const handleChangeTemplateField = (field: keyof ITemplate) => (value: ITemplate[keyof ITemplate]) => { - const workflow = template; - setTemplateStatus(ETemplateStatus.Saving); - - if (field === 'isActive') { - const newWorkflow: ITemplate = { - ...workflow, - isActive: value as boolean, - }; - - setTemplate(newWorkflow); - submitDebounced(); - - return; - } - - const updatedWorkflow: ITemplate = { - ...workflow, - [field]: value, - isActive: false, - }; - - const newWorkflow = (field === 'kickoff' || field === 'tasks') - ? cleanTemplateReferences(updatedWorkflow) - : updatedWorkflow; - - setTemplate(newWorkflow); - submitDebounced(); - }; - const changeTasks = (newTasks: ITemplateTask[]) => { - handleChangeTemplateField('tasks')(newTasks); + setFieldValue('tasks', newTasks, false); }; const handleRemoveTask = (targetTask: ITemplateTask) => () => { @@ -381,7 +352,7 @@ export function TemplateEdit({ return task; }); - handleChangeTemplateField('tasks')(newTasks); + changeTasks(newTasks); }; const addDelay = (targetTask: ITemplateTask) => () => { @@ -436,8 +407,6 @@ export function TemplateEdit({ setOpenedDelays({ ...openedDelays, [taskUUID]: !isDelayOpen }); }; - const submitDebounced = debounce(350, saveTemplate); - const getTaskListItem = (task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]) => { const isTaskOpen = Boolean(openedTasks[task.uuid]); const isDelayOpen = Boolean(openedDelays[task.uuid]); @@ -472,32 +441,39 @@ export function TemplateEdit({ } return ( -
- - -
-
- -
-
- {!accessConditions && } -
-
- + +
+ + +
+
+ +
+
+ {!accessConditions && } +
+
+ +
+ {sortedTasks().map(getTaskListItem)} + +
- {sortedTasks().map(getTaskListItem)} - -
-
+ ); } diff --git a/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx b/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx index 2854dc40e..8995fa57d 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx @@ -1,56 +1,31 @@ import React, { useState } from 'react'; import { useIntl } from 'react-intl'; -import { debounce } from 'throttle-debounce'; -import { useDispatch, useSelector } from 'react-redux'; import StickyBox from 'react-sticky-box'; import { EditableText } from '../../UI'; import { NAVBAR_HEIGHT } from '../../../constants/defaultValues'; -import { getTemplateData } from '../../../redux/selectors/template'; -import { saveTemplate, setTemplate, setTemplateStatus } from '../../../redux/actions'; -import { ETemplateStatus } from '../../../types/redux'; import { ITemplate } from '../../../types/template'; import { TemplateControllsContainer } from '../TemplateControlls'; import { isArrayWithItems } from '../../../utils/helpers'; import { IInfoWarningProps, InfoWarningsModal } from '../InfoWarningsModal'; import { TemplateLastUpdateInfo } from '../TemplateLastUpdateInfo'; import { RichEditor } from '../../RichEditor'; +import { useTemplateField } from '../useTemplateForm'; import styles from './TemplateSettings.css'; export function TemplateSettings() { const { formatMessage } = useIntl(); - const dispatch = useDispatch(); - const template = useSelector(getTemplateData); + const { values, setFieldValue } = useTemplateField(); const [isInfoWarningsModaOpen, setIsInfoWarningsModaOpen] = useState(false); const [infoWarnings, setInfoWarnings] = useState([]); const handleChangeTemplateField = (field: keyof ITemplate) => (value: ITemplate[keyof ITemplate]) => { - const workflow = template; - let newWorkflow: ITemplate; - dispatch(setTemplateStatus(ETemplateStatus.Saving)); - - if (field === 'isActive') { - newWorkflow = { - ...workflow, - isActive: value as boolean, - }; - } else { - newWorkflow = { - ...workflow, - [field]: value, - isActive: false, - }; - } - - dispatch(setTemplate(newWorkflow)); - submitDebounced(); + setFieldValue(field as string, value, false); }; const handleChangeTextField = (field: keyof ITemplate) => (value: string) => handleChangeTemplateField(field)(value); - const submitDebounced = debounce(350, () => dispatch(saveTemplate())); - const handleSetInfoWarnings = (infoWarningsLocal: ((props: IInfoWarningProps) => JSX.Element)[]) => { if (isArrayWithItems(infoWarningsLocal)) { setIsInfoWarningsModaOpen(true); @@ -69,7 +44,7 @@ export function TemplateSettings() { {renderInfoWarningsModal()}
{ handleChangeTextField('description')(value); return Promise.resolve(value); @@ -92,9 +67,9 @@ export function TemplateSettings() { - {(template.updatedBy || template.dateUpdated) && ( + {(values.updatedBy || values.dateUpdated) && (
- +
)} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx new file mode 100644 index 000000000..0d6e87810 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -0,0 +1,214 @@ +import * as React from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { FormikProvider, useFormik, useFormikContext } from 'formik'; +import { useDispatch } from 'react-redux'; + +import { ITemplate } from '../../types/template'; +import { patchTemplate } from '../../redux/actions'; + +type TSetFieldValue = (field: string, value: unknown, shouldValidate?: boolean) => void; +type TSetValues = (values: ITemplate, shouldValidate?: boolean) => void; + +interface ITemplateFieldContextValue { + values: ITemplate; + setFieldValue: TSetFieldValue; + setValues: TSetValues; +} + +const TemplateFieldContext = createContext(null); + +export { TemplateFieldContext }; + +/** + * Access the root Edit Template form state + the wrapped setters. + * + * `setFieldValue` / `setValues` mark the form as user-dirty before delegating + * to Formik, so `TemplateFormPersistProvider` knows which value changes come + * from user edits (and should be saved) vs. external reinitializes from the + * server (which must not re-trigger a save — otherwise the server-stamped + * `dateUpdated`/`publicUrl` fields would cause an infinite save loop). + * + * Use this instead of `useFormikContext().setFieldValue` everywhere on the + * Edit Template page so saving stays centralized. + */ +export function useTemplateField(): ITemplateFieldContextValue { + const ctx = useContext(TemplateFieldContext); + + if (!ctx) { + throw new Error('useTemplateField must be used inside the Edit Template form provider'); + } + + return ctx; +} + +/** + * Root Formik context for the whole Edit Template page. + * + * The entire `ITemplate` (name, description, kickoff, tasks, owners, share + * settings, toggles, ...) lives in a single Formik state. Every field binds to + * it via `useTemplateField`; nothing dispatches Redux field updates from + * individual inputs anymore. Saving happens from one place only — + * `TemplateFormPersistProvider` — which dispatches a single `patchTemplate` + * action for user edits (the saga then debounces + saves). Explicit "submit" + * flows (e.g. activating the template) still go through `patchTemplate` with + * callbacks, so both onChange and submit share one save path. + */ +export function useTemplateForm(initialValues: ITemplate) { + const formik = useFormik({ + initialValues, + enableReinitialize: true, + onSubmit: () => undefined, + }); + + const dirtyRef = useRef(false); + + const setFieldValue = useCallback( + (field, value, shouldValidate) => { + dirtyRef.current = true; + formik.setFieldValue(field, value, shouldValidate); + }, + [formik], + ); + + const setValues = useCallback( + (values, shouldValidate) => { + dirtyRef.current = true; + formik.setValues(values, shouldValidate); + }, + [formik], + ); + + return { formik, setFieldValue, setValues, dirtyRef }; +} + +interface ITemplateFormProps { + formik: ReturnType>; + setFieldValue: TSetFieldValue; + setValues: TSetValues; + dirtyRef: React.MutableRefObject; + children: React.ReactNode; +} + +/** + * Bundles the Formik provider, the wrapped-setter context, and the single + * persist provider so `TemplateEdit` only has to render ``. + */ +export function TemplateForm({ formik, setFieldValue, setValues, dirtyRef, children }: ITemplateFormProps) { + const { values } = useFormikContext(); + const fieldContextValue = useMemo( + () => ({ values, setFieldValue, setValues }), + [values, setFieldValue, setValues], + ); + + return ( + + + {children} + + + ); +} + +function getChangedFields(previous: ITemplate, next: ITemplate): Partial { + const changedFields: Partial = {}; + + (Object.keys(next) as (keyof ITemplate)[]).forEach((key) => { + if (previous[key] !== next[key]) { + (changedFields[key] as ITemplate[keyof ITemplate]) = next[key]; + } + }); + + return changedFields; +} + +interface ITemplateFormPersistProviderProps { + dirtyRef: React.MutableRefObject; + children: React.ReactNode; +} + +/** + * Single save point for every user edit on the Edit Template page. + * + * On each Formik values change we compute a shallow top-level diff and — only + * when the change was initiated by a user via the wrapped `setFieldValue`/ + * `setValues` (i.e. `dirtyRef` is true) — dispatch `patchTemplate`. The + * `patchTemplate` saga owns the actual debounce + API call. External + * reinitializes (template load, save response with server-stamped fields, + * owners normalization, ...) update `previousValuesRef` but never dispatch, so + * they cannot cause a save loop. + */ +export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFormPersistProviderProps) { + const { values } = useFormikContext(); + const dispatch = useDispatch(); + const previousValuesRef = useRef(values); + const valuesRef = useRef(values); + const dispatchRef = useRef(dispatch); + const dirtyRefRef = useRef(dirtyRef); + + valuesRef.current = values; + dispatchRef.current = dispatch; + dirtyRefRef.current = dirtyRef; + + const flushPersist = useCallback((currentValues: ITemplate) => { + if (previousValuesRef.current === currentValues) { + return; + } + + const changedFields = getChangedFields(previousValuesRef.current, currentValues); + const isUserEdit = dirtyRefRef.current.current; + previousValuesRef.current = currentValues; + dirtyRefRef.current.current = false; + + if (isUserEdit && Object.keys(changedFields).length > 0) { + dispatchRef.current(patchTemplate({ changedFields })); + } + }, []); + + useEffect(() => { + if (previousValuesRef.current === values) { + return undefined; + } + + const capturedValues = values; + const timeoutId = window.setTimeout(() => { + flushPersist(valuesRef.current); + }, 0); + + return () => { + window.clearTimeout(timeoutId); + flushPersist(capturedValues); + }; + }, [values, flushPersist]); + + return children as React.ReactElement; +} + +const TaskFormScopeContext = createContext(null); + +interface ITaskFormScopeProviderProps { + taskUuid: string; + children: React.ReactNode; +} + +/** + * Scopes the task form sections to a single task inside the root `ITemplate` + * Formik context. Descendants call `useTaskForm()` (no args) and get the + * matching task + updaters bound to `tasks[index].`. + */ +export function TaskFormScopeProvider({ taskUuid, children }: ITaskFormScopeProviderProps) { + return {children}; +} + +/** + * Returns the uuid of the task currently scoped via `TaskFormScopeProvider`. + * Throws if used outside the provider. + */ +export function useTaskFormScope(): string { + const taskUuid = useContext(TaskFormScopeContext); + + if (taskUuid === null) { + throw new Error('useTaskForm must be used inside '); + } + + return taskUuid; +} From db15abd8d61601be986ab14794a8a7c7f5dc22d1 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 17:03:53 +0300 Subject: [PATCH 11/39] 59876 fix(template): keep Formik in sync with saga deactivation and task variables --- .../TemplateEdit/TaskForm/TaskForm.tsx | 35 ++-- .../TemplateEdit/TaskForm/container.ts | 16 +- .../__tests__/useTemplateForm.test.tsx | 171 ++++++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 45 ++++- 4 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx index 33eeeb19d..4b669a802 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -2,45 +2,52 @@ import * as React from 'react'; import { useMemo, useRef } from 'react'; import { TUserListItem } from '../../../types/user'; -import { IKickoff, ITemplateTask } from '../../../types/template'; -import { TTaskVariable, TTaskFormPart } from '../types'; +import { ITemplateTask } from '../../../types/template'; +import { TTaskFormPart } from '../types'; -import { getSystemVariables } from './utils/getTaskVariables'; +import { getSystemVariables, getTaskVariables, getVariables } from './utils/getTaskVariables'; import { TaskFormHeader } from './TaskFormHeader'; import { TaskFormSections } from './TaskFormSections'; -import { TaskFormScopeProvider } from '../useTemplateForm'; +import { TaskFormScopeProvider, useTemplateField } from '../useTemplateForm'; import styles from '../TemplateEdit.css'; export interface ITaskFormProps { - listVariables: TTaskVariable[]; - templateVariables: TTaskVariable[]; task: ITemplateTask; users: TUserListItem[]; isSubscribed: boolean; scrollTarget: TTaskFormPart; accountId: number; isTeamInvitesModalOpen: boolean; - tasks: ITemplateTask[]; - kickoff: IKickoff; } export function TaskForm({ - listVariables, - templateVariables, task, users, isSubscribed, accountId, scrollTarget, isTeamInvitesModalOpen, - tasks, - kickoff, - templateId, -}: ITaskFormProps & { templateId: number | undefined }) { +}: ITaskFormProps) { if (!task) return null; const wrapperRef = useRef(null); + // `kickoff`, `tasks`, and the variable lists are read from the Formik form + // state rather than Redux. Field edits land in Formik first and only get + // patched into Redux later (debounced by `TemplateFormPersistProvider`), so + // reading from Redux here would render sections like conditions, due dates, + // and return-to against stale task/kickoff data. + const { values } = useTemplateField(); + const { kickoff, tasks, id: templateId } = values; + + const listVariables = useMemo( + () => getTaskVariables(kickoff, tasks, task, templateId), + [kickoff, tasks, task, templateId], + ); + const templateVariables = useMemo( + () => getVariables({ kickoff, tasks, templateId }), + [kickoff, tasks, templateId], + ); const listSystemVariables = useMemo(() => [ ...getSystemVariables(), ...listVariables, diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts index 0dee5cc58..6094b73e2 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts +++ b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts @@ -2,34 +2,22 @@ import { connect } from 'react-redux'; import { TaskForm, ITaskFormProps } from './TaskForm'; import { IApplicationState } from '../../../types/redux'; -import { getTaskVariables, getVariables } from './utils/getTaskVariables'; import { getIsUserSubsribed } from '../../../redux/selectors/user'; -type TStoreProps = Pick< - ITaskFormProps, - 'listVariables' | 'templateVariables' | 'isSubscribed' | 'accountId' | 'isTeamInvitesModalOpen' | 'kickoff' | 'tasks' -> & { templateId: number | undefined }; +type TStoreProps = Pick; type TOwnProps = Pick; -const mapStateToProps = (state: IApplicationState, { task }: TOwnProps): TStoreProps => { +const mapStateToProps = (state: IApplicationState): TStoreProps => { const { - template: { - data: { kickoff, tasks, id }, - }, authUser: { account }, team: { isInvitesPopupOpen: isTeamInvitesModalOpen }, } = state; const isSubscribed = getIsUserSubsribed(state); return { - listVariables: getTaskVariables(kickoff, tasks, task, id), - templateVariables: getVariables({ kickoff, tasks, templateId: id }), isSubscribed, accountId: account.id || -1, isTeamInvitesModalOpen, - kickoff, - tasks, - templateId: id, }; }; diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx new file mode 100644 index 000000000..fe6601d3c --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -0,0 +1,171 @@ +/// +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import { ITemplate, ITemplateTask } from '../../../types/template'; +import { TemplateForm, useTemplateField, useTemplateForm } from '../useTemplateForm'; +import { patchTemplate } from '../../../redux/actions'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../../../redux/actions', () => ({ + ...jest.requireActual('../../../redux/actions'), + patchTemplate: jest.fn((payload: unknown) => ({ type: 'PATCH_TEMPLATE', payload })), +})); + +const makeTask = (overrides: Partial = {}): ITemplateTask => + ({ + apiName: 'task-1', + number: 1, + name: 'Test Task', + description: '', + delay: null, + rawDueDate: null as any, + requireCompletionByAll: false, + skipForStarter: false, + rawPerformers: [], + fields: [], + uuid: 'uuid-1', + conditions: [], + checklists: [], + revertTask: null, + ancestors: [], + ...overrides, + }) as ITemplateTask; + +const makeTemplate = (overrides: Partial = {}): ITemplate => + ({ + id: 1, + name: 'Template', + description: '', + isActive: true, + finalizable: false, + completionNotification: false, + reminderNotification: false, + dateUpdated: null, + updatedBy: null, + owners: [], + kickoff: { description: '', fields: [] } as any, + tasks: [makeTask()], + isPublic: false, + publicUrl: null, + publicSuccessUrl: null, + isEmbedded: false, + embedUrl: null, + wfNameTemplate: null, + tasksCount: 1, + performersCount: 0, + ...overrides, + }) as ITemplate; + +interface ISpyHandle { + values: ITemplate; + setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void; +} + +// Drain the persist provider's `setTimeout(0)` flush plus the follow-up tick it +// schedules when it mirrors the saga's `isActive` deactivation, so no React +// state update leaks outside `act`. +const flushPersist = () => + act(async () => { + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + }); + +function TemplateFormHarness({ + initialTemplate, + spy, +}: { + initialTemplate: ITemplate; + spy: (handle: ISpyHandle) => void; +}) { + const { formik, setFieldValue, setValues, dirtyRef } = useTemplateForm(initialTemplate); + + const Spy: React.FC = () => { + const { values, setFieldValue: contextSetFieldValue } = useTemplateField(); + spy({ values, setFieldValue: contextSetFieldValue }); + return null; + }; + + return ( + + + + ); +} + +describe('TemplateFormPersistProvider deactivation', () => { + beforeEach(() => { + (patchTemplate as unknown as jest.Mock).mockClear(); + }); + + it('flips isActive to false in Formik immediately when a non-activation field changes', async () => { + const template = makeTemplate({ isActive: true, description: 'old' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'new description', false); + }); + + await flushPersist(); + + expect(handle!.values.isActive).toBe(false); + expect(patchTemplate).toHaveBeenCalledTimes(1); + }); + + it('does not deactivate when only an activation-related field changes', async () => { + const template = makeTemplate({ isActive: true, isPublic: false }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('isPublic', true, false); + }); + + await flushPersist(); + + expect(handle!.values.isActive).toBe(true); + expect(patchTemplate).toHaveBeenCalledTimes(1); + }); + + it('does not deactivate when only kickoff.description changes (matches saga special case)', async () => { + const template = makeTemplate({ + isActive: true, + kickoff: { description: 'old kickoff', fields: [] } as any, + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('kickoff', { description: 'new kickoff', fields: [] }, false); + }); + + await flushPersist(); + + expect(handle!.values.isActive).toBe(true); + }); + + it('deactivates when kickoff changes but description stays the same', async () => { + const template = makeTemplate({ + isActive: true, + kickoff: { description: 'same', fields: [] } as any, + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('kickoff', { description: 'same', fields: [{ apiName: 'f-1' }] }, false); + }); + + await flushPersist(); + + expect(handle!.values.isActive).toBe(false); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 0d6e87810..63fbe5af1 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -94,7 +94,7 @@ interface ITemplateFormProps { * persist provider so `TemplateEdit` only has to render ``. */ export function TemplateForm({ formik, setFieldValue, setValues, dirtyRef, children }: ITemplateFormProps) { - const { values } = useFormikContext(); + const { values } = formik; const fieldContextValue = useMemo( () => ({ values, setFieldValue, setValues }), [values, setFieldValue, setValues], @@ -121,6 +121,32 @@ function getChangedFields(previous: ITemplate, next: ITemplate): Partial, + previousTemplate: ITemplate, +): boolean { + let shouldDeactivate = Object.keys(changedFields).some( + (key) => !NON_DEACTIVATIVE_FIELDS.includes(key as keyof ITemplate), + ); + + if (Object.keys(changedFields).length === 1 && Object.prototype.hasOwnProperty.call(changedFields, 'kickoff')) { + shouldDeactivate = + changedFields.kickoff?.description === previousTemplate.kickoff.description + ? shouldDeactivate + : false; + } + + return shouldDeactivate; +} + interface ITemplateFormPersistProviderProps { dirtyRef: React.MutableRefObject; children: React.ReactNode; @@ -136,30 +162,43 @@ interface ITemplateFormPersistProviderProps { * reinitializes (template load, save response with server-stamped fields, * owners normalization, ...) update `previousValuesRef` but never dispatch, so * they cannot cause a save loop. + * + * When a user edit touches a non-activation field, we also flip `isActive` to + * false in Formik right away — the saga makes the same call server-side, but + * only reinitializes Formik after the save round-trip, so without this the + * controls would keep rendering the template as active (no draft warning, no + * enable button) until Redux catches up. */ export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFormPersistProviderProps) { - const { values } = useFormikContext(); + const { values, setFieldValue } = useFormikContext(); const dispatch = useDispatch(); const previousValuesRef = useRef(values); const valuesRef = useRef(values); const dispatchRef = useRef(dispatch); const dirtyRefRef = useRef(dirtyRef); + const setFieldValueRef = useRef(setFieldValue); valuesRef.current = values; dispatchRef.current = dispatch; dirtyRefRef.current = dirtyRef; + setFieldValueRef.current = setFieldValue; const flushPersist = useCallback((currentValues: ITemplate) => { if (previousValuesRef.current === currentValues) { return; } - const changedFields = getChangedFields(previousValuesRef.current, currentValues); + const previousValues = previousValuesRef.current; + const changedFields = getChangedFields(previousValues, currentValues); const isUserEdit = dirtyRefRef.current.current; previousValuesRef.current = currentValues; dirtyRefRef.current.current = false; if (isUserEdit && Object.keys(changedFields).length > 0) { + if (shouldDeactivateTemplate(changedFields, previousValues)) { + setFieldValueRef.current('isActive', false, false); + } + dispatchRef.current(patchTemplate({ changedFields })); } }, []); From 36832b0b2a11a3155fe1c13a8233f8df46a14ab7 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 17:16:52 +0300 Subject: [PATCH 12/39] 59876 fix(template): preserve pending template edits on activation --- .../TemplateControlls/TemplateControlls.tsx | 8 +- .../__tests__/useTemplateForm.test.tsx | 96 ++++++++++++++++++- .../TemplateEdit/useTemplateForm.tsx | 57 ++++++++--- frontend/src/public/redux/template/saga.ts | 6 +- 4 files changed, 148 insertions(+), 19 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index b9cbc9023..d9ec3e58b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -39,7 +39,7 @@ import { checkShowDraftTemplateWarning } from '../../Templates'; import styles from './TemplateControlls.css'; import { getSubscriptionPlan, getIsUserSubsribed } from '../../../redux/selectors/user'; import { ESubscriptionPlan } from '../../../types/account'; -import { useTemplateField } from '../useTemplateForm'; +import { useTemplateField, useTemplatePersist } from '../useTemplateForm'; export interface ITemplateControllsProps { cloneTemplate(payload: TCloneTemplatePayload): void; @@ -60,6 +60,7 @@ export function TemplateControlls({ const { formatMessage } = intl; const dispatch = useDispatch(); const { values: template, setFieldValue } = useTemplateField(); + const { consumePendingChanges } = useTemplatePersist(); const templateStatus = useSelector(getTemplateStatus); const isSubscribed = useSelector(getIsUserSubsribed); const billingPlan = useSelector(getSubscriptionPlan); @@ -111,7 +112,7 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { - patchTemplate({ changedFields: { isActive: false } }); + patchTemplate({ changedFields: { ...consumePendingChanges(), isActive: false } }); return; } @@ -127,8 +128,11 @@ export function TemplateControlls({ setIsTemplateActivating(true); + const pendingChanges = consumePendingChanges(); + patchTemplate({ changedFields: { + ...pendingChanges, isActive: true, }, onSuccess: () => { diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index fe6601d3c..ef3a56903 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { ITemplate, ITemplateTask } from '../../../types/template'; -import { TemplateForm, useTemplateField, useTemplateForm } from '../useTemplateForm'; +import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist } from '../useTemplateForm'; import { patchTemplate } from '../../../redux/actions'; jest.mock('react-redux', () => ({ @@ -63,6 +63,7 @@ const makeTemplate = (overrides: Partial = {}): ITemplate => interface ISpyHandle { values: ITemplate; setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void; + consumePendingChanges: () => Partial; } // Drain the persist provider's `setTimeout(0)` flush plus the follow-up tick it @@ -85,7 +86,8 @@ function TemplateFormHarness({ const Spy: React.FC = () => { const { values, setFieldValue: contextSetFieldValue } = useTemplateField(); - spy({ values, setFieldValue: contextSetFieldValue }); + const { consumePendingChanges } = useTemplatePersist(); + spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges }); return null; }; @@ -168,4 +170,94 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(handle!.values.isActive).toBe(false); }); + + it('lets submit consume pending edits before the autosave dispatches', async () => { + const template = makeTemplate({ isActive: true, owners: [] }); + let handle: ISpyHandle | null = null; + const owners = [{ id: 1, role: 'owner' }] as any; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('owners', owners, false); + }); + + let pendingChanges: Partial = {}; + act(() => { + pendingChanges = handle!.consumePendingChanges(); + }); + await flushPersist(); + + expect(pendingChanges).toEqual({ owners }); + expect(patchTemplate).not.toHaveBeenCalled(); + }); + + it('persists the latest value when two edits land before the deferred flush runs', async () => { + const template = makeTemplate({ isActive: true, description: 'old' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'first edit', false); + handle!.setFieldValue('description', 'second edit', false); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + description: 'second edit', + }); + }); + + it('persists the latest value when two separate edits each render before the deferred flush runs', async () => { + const template = makeTemplate({ isActive: false, description: 'old' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + // Two separate user events (separate acts) — each produces its own render + // and its own effect, but neither setTimeout(0) flush is allowed to fire + // until both have landed. This is the scenario the persist provider's + // effect cleanup is meant to handle: the first effect's cleanup must not + // swallow the second edit. + act(() => { + handle!.setFieldValue('description', 'first edit', false); + }); + act(() => { + handle!.setFieldValue('description', 'second edit', false); + }); + await flushPersist(); + + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + const descriptionPatches = calls + .map((c: any[]) => c[0]?.changedFields?.description) + .filter((v: unknown) => v !== undefined); + + expect(descriptionPatches).toEqual(['second edit']); + expect(patchTemplate).toHaveBeenCalledTimes(1); + }); + + it('still persists a second edit when the first triggers an isActive deactivation', async () => { + const template = makeTemplate({ isActive: true, description: 'old' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'first edit', false); + }); + act(() => { + handle!.setFieldValue('description', 'second edit', false); + }); + await flushPersist(); + + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + const descriptionPatches = calls + .map((c: any[]) => c[0]?.changedFields?.description) + .filter((v: unknown) => v !== undefined); + + expect(descriptionPatches).toEqual(['second edit']); + expect(handle!.values.isActive).toBe(false); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 63fbe5af1..759a7d6e2 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -15,7 +15,12 @@ interface ITemplateFieldContextValue { setValues: TSetValues; } +interface ITemplatePersistContextValue { + consumePendingChanges(): Partial; +} + const TemplateFieldContext = createContext(null); +const TemplatePersistContext = createContext(null); export { TemplateFieldContext }; @@ -41,6 +46,16 @@ export function useTemplateField(): ITemplateFieldContextValue { return ctx; } +export function useTemplatePersist(): ITemplatePersistContextValue { + const ctx = useContext(TemplatePersistContext); + + if (!ctx) { + throw new Error('useTemplatePersist must be used inside the Edit Template form provider'); + } + + return ctx; +} + /** * Root Formik context for the whole Edit Template page. * @@ -133,6 +148,10 @@ function shouldDeactivateTemplate( changedFields: Partial, previousTemplate: ITemplate, ): boolean { + if (changedFields.isActive === true) { + return false; + } + let shouldDeactivate = Object.keys(changedFields).some( (key) => !NON_DEACTIVATIVE_FIELDS.includes(key as keyof ITemplate), ); @@ -183,43 +202,57 @@ export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFor dirtyRefRef.current = dirtyRef; setFieldValueRef.current = setFieldValue; - const flushPersist = useCallback((currentValues: ITemplate) => { - if (previousValuesRef.current === currentValues) { - return; + const consumePendingChanges = useCallback(() => { + if (previousValuesRef.current === valuesRef.current) { + return {}; } - const previousValues = previousValuesRef.current; - const changedFields = getChangedFields(previousValues, currentValues); + const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); const isUserEdit = dirtyRefRef.current.current; - previousValuesRef.current = currentValues; + previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; - if (isUserEdit && Object.keys(changedFields).length > 0) { + return isUserEdit ? changedFields : {}; + }, []); + + const flushPersist = useCallback(() => { + const previousValues = previousValuesRef.current; + const changedFields = consumePendingChanges(); + + if (Object.keys(changedFields).length > 0) { if (shouldDeactivateTemplate(changedFields, previousValues)) { setFieldValueRef.current('isActive', false, false); } dispatchRef.current(patchTemplate({ changedFields })); } - }, []); + }, [consumePendingChanges]); useEffect(() => { if (previousValuesRef.current === values) { return undefined; } - const capturedValues = values; const timeoutId = window.setTimeout(() => { - flushPersist(valuesRef.current); + flushPersist(); }, 0); return () => { window.clearTimeout(timeoutId); - flushPersist(capturedValues); + flushPersist(); }; }, [values, flushPersist]); - return children as React.ReactElement; + const persistContextValue = useMemo( + () => ({ consumePendingChanges }), + [consumePendingChanges], + ); + + return ( + + {children as React.ReactElement} + + ); } const TaskFormScopeContext = createContext(null); diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index 582cac366..d4d221ac6 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -103,9 +103,9 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }: yield put(setTemplateStatus(ETemplateStatus.Saving)); const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; - let shouldDeactivateTemplate = Object.keys(changedFields).some( - (key) => !nonDeactivativeFields.includes(key as keyof ITemplate), - ); + let shouldDeactivateTemplate = changedFields.isActive === true + ? false + : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { shouldDeactivateTemplate = From c9bcf34ceb2efb0704c844fda304affad9dd0a10 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 17:30:17 +0300 Subject: [PATCH 13/39] 59876 fix(template): prevent stale autosave and lost Formik edits --- .../components/TemplateEdit/TemplateEdit.tsx | 12 ++++++--- .../__tests__/useTemplateForm.test.tsx | 19 ++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 848d98879..7efda0d1a 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -86,7 +86,6 @@ export function TemplateEdit({ loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const { owners } = template; const { formik, setFieldValue, setValues, dirtyRef } = useTemplateForm(template); const { tasks } = formik.values; const billingPlan = useSelector(getSubscriptionPlan); @@ -138,8 +137,15 @@ export function TemplateEdit({ } if (users.length !== prevUsers?.length) { - const newTemplateOwners = getNormalizedTemplateOwners(owners, accessConditions, users); - setTemplate({ ...template, owners: newTemplateOwners }); + // Build from the current Formik values, not the Redux `template` prop. + // Field edits live in Formik until `TemplateFormPersistProvider` flushes + // them to Redux, so the Redux snapshot can lag behind. Formik uses + // `enableReinitialize`, so a `setTemplate` built from the stale Redux + // prop would reset Formik to that snapshot and discard any uncommitted + // edits. Spreading from `formik.values` carries those edits into the + // new Redux state so the reinitialize is a no-op for them. + const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, accessConditions, users); + setTemplate({ ...formik.values, owners: newTemplateOwners }); } }, [prevTemplate, prevLocation, prevUsers]); diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index ef3a56903..a36fe96ed 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -238,6 +238,25 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).toHaveBeenCalledTimes(1); }); + it('does not dispatch patchTemplate on unmount when pending edits were not flushed', async () => { + const template = makeTemplate({ isActive: true, description: 'old' }); + let handle: ISpyHandle | null = null; + + const { unmount } = render( + { handle = h; }} />, + ); + + act(() => { + handle!.setFieldValue('description', 'discarded edit', false); + }); + + unmount(); + + await flushPersist(); + + expect(patchTemplate).not.toHaveBeenCalled(); + }); + it('still persists a second edit when the first triggers an isActive deactivation', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 759a7d6e2..5b8a1cc31 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -76,6 +76,21 @@ export function useTemplateForm(initialValues: ITemplate) { }); const dirtyRef = useRef(false); + const initialValuesRef = useRef(initialValues); + + // Formik reinitializes `values` from `initialValues` whenever the Redux + // `template` prop changes (enableReinitialize). That change is NOT a user + // edit, so a dirty flag left set by a prior wrapped setter (e.g. the + // `setFieldValue('isActive', false)` issued by `flushPersist` right before + // its `patchTemplate` dispatch) must be cleared before the persist effect + // runs — otherwise the next flush treats the reinitialized values as a user + // edit and dispatches a `patchTemplate` diffing against an outdated + // `previousValuesRef`. Doing this during render (rather than in an effect) + // guarantees the flag is cleared before the child persist effect fires. + if (initialValuesRef.current !== initialValues) { + initialValuesRef.current = initialValues; + dirtyRef.current = false; + } const setFieldValue = useCallback( (field, value, shouldValidate) => { @@ -228,6 +243,17 @@ export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFor } }, [consumePendingChanges]); + // Registered before the values effect so its cleanup runs first on unmount + // and clears the pending diff before the values effect cleanup calls + // `flushPersist`, preventing discarded edits from being dispatched when the + // user leaves the page (e.g. "Discard changes" + navigate away). + useEffect(() => { + return () => { + previousValuesRef.current = valuesRef.current; + dirtyRefRef.current.current = false; + }; + }, []); + useEffect(() => { if (previousValuesRef.current === values) { return undefined; From b267cc957f4fd05726d1030e630deb010cf5ab3a Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 18:00:17 +0300 Subject: [PATCH 14/39] 59876 fix(template): preserve pending template edits during activation --- .../TemplateControlls/TemplateControlls.tsx | 12 +- .../components/TemplateEdit/TemplateEdit.tsx | 4 +- .../__tests__/useTemplateForm.test.tsx | 208 +++++++++++++++++- .../TemplateEdit/useTemplateForm.tsx | 203 +++++++++++++---- 4 files changed, 381 insertions(+), 46 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index d9ec3e58b..ef71c448b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -60,7 +60,7 @@ export function TemplateControlls({ const { formatMessage } = intl; const dispatch = useDispatch(); const { values: template, setFieldValue } = useTemplateField(); - const { consumePendingChanges } = useTemplatePersist(); + const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges } = useTemplatePersist(); const templateStatus = useSelector(getTemplateStatus); const isSubscribed = useSelector(getIsUserSubsribed); const billingPlan = useSelector(getSubscriptionPlan); @@ -112,7 +112,13 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { - patchTemplate({ changedFields: { ...consumePendingChanges(), isActive: false } }); + const pendingChanges = consumePendingChanges(); + + patchTemplate({ + changedFields: { ...pendingChanges, isActive: false }, + onSuccess: confirmConsumedChanges, + onFailed: revertConsumedChanges, + }); return; } @@ -136,6 +142,7 @@ export function TemplateControlls({ isActive: true, }, onSuccess: () => { + confirmConsumedChanges(); setIsTemplateActivating(false); if (redirectUrl) { @@ -143,6 +150,7 @@ export function TemplateControlls({ } }, onFailed: () => { + revertConsumedChanges(); setIsTemplateActivating(false); }, }); diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 7efda0d1a..28a09ab9b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -86,7 +86,7 @@ export function TemplateEdit({ loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const { formik, setFieldValue, setValues, dirtyRef } = useTemplateForm(template); + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(template); const { tasks } = formik.values; const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; @@ -452,6 +452,8 @@ export function TemplateEdit({ setFieldValue={setFieldValue} setValues={setValues} dirtyRef={dirtyRef} + pendingUserEditsRef={pendingUserEditsRef} + persistBaselineSyncRef={persistBaselineSyncRef} >
diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index a36fe96ed..36e6a1512 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -64,6 +64,8 @@ interface ISpyHandle { values: ITemplate; setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void; consumePendingChanges: () => Partial; + confirmConsumedChanges: () => void; + revertConsumedChanges: () => void; } // Drain the persist provider's `setTimeout(0)` flush plus the follow-up tick it @@ -82,22 +84,39 @@ function TemplateFormHarness({ initialTemplate: ITemplate; spy: (handle: ISpyHandle) => void; }) { - const { formik, setFieldValue, setValues, dirtyRef } = useTemplateForm(initialTemplate); + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(initialTemplate); const Spy: React.FC = () => { const { values, setFieldValue: contextSetFieldValue } = useTemplateField(); - const { consumePendingChanges } = useTemplatePersist(); - spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges }); + const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges } = useTemplatePersist(); + spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges, confirmConsumedChanges, revertConsumedChanges }); return null; }; return ( - + ); } +function StatefulTemplateFormHarness({ + initialTemplate, + spy, +}: { + initialTemplate: ITemplate; + spy: (handle: ISpyHandle) => void; +}) { + return ; +} + describe('TemplateFormPersistProvider deactivation', () => { beforeEach(() => { (patchTemplate as unknown as jest.Mock).mockClear(); @@ -192,6 +211,47 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + it('restores the persist baseline when an explicit submit fails after consume', async () => { + const template = makeTemplate({ isActive: true, owners: [] }); + let handle: ISpyHandle | null = null; + const owners = [{ id: 1, role: 'owner' }] as any; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('owners', owners, false); + }); + + act(() => { + handle!.consumePendingChanges(); + handle!.revertConsumedChanges(); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ owners }); + }); + + it('does not restore the persist baseline after an explicit submit succeeds', async () => { + const template = makeTemplate({ isActive: true, owners: [] }); + let handle: ISpyHandle | null = null; + const owners = [{ id: 1, role: 'owner' }] as any; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('owners', owners, false); + }); + + act(() => { + handle!.consumePendingChanges(); + handle!.confirmConsumedChanges(); + }); + await flushPersist(); + + expect(patchTemplate).not.toHaveBeenCalled(); + }); + it('persists the latest value when two edits land before the deferred flush runs', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; @@ -280,3 +340,143 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(handle!.values.isActive).toBe(false); }); }); + +describe('useTemplateForm reinitialize', () => { + function HookHarness({ + currentTemplate, + onReady, + }: { + currentTemplate: ITemplate; + onReady(result: ReturnType): void; + }) { + const result = useTemplateForm(currentTemplate); + onReady(result); + return null; + } + + it('merges pending user edits into resolved Formik initialValues', () => { + const template = makeTemplate({ name: 'Original', dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('name', 'unsaved edit', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.name).toBe('unsaved edit'); + expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + }); +}); + +describe('TemplateFormPersistProvider reinitialize', () => { + beforeEach(() => { + (patchTemplate as unknown as jest.Mock).mockClear(); + }); + + it('preserves Formik-only edits when Redux reinitializes during an in-flight save', async () => { + const template = makeTemplate({ isActive: true, name: 'Original', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} + />, + ); + + act(() => { + handle!.setFieldValue('description', 'saved edit', false); + }); + await flushPersist(); + + rerender( + { handle = h; }} + />, + ); + await flushPersist(); + + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + handle!.setFieldValue('name', 'unsaved edit', false); + rerender( + { handle = h; }} + />, + ); + }); + + await flushPersist(); + + expect(handle!.values.name).toBe('unsaved edit'); + expect(handle!.values.description).toBe('saved edit'); + expect(handle!.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + name: 'unsaved edit', + }); + }); + + it('does not treat a Redux reinitialize as a user edit when Formik has no pending changes', async () => { + const template = makeTemplate({ isActive: true, description: 'old', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} + />, + ); + + act(() => { + handle!.setFieldValue('description', 'new', false); + }); + await flushPersist(); + + (patchTemplate as unknown as jest.Mock).mockClear(); + + rerender( + { handle = h; }} + />, + ); + + await flushPersist(); + + expect(patchTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 5b8a1cc31..2bd70b564 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef } fr import { FormikProvider, useFormik, useFormikContext } from 'formik'; import { useDispatch } from 'react-redux'; -import { ITemplate } from '../../types/template'; +import { ITemplate, ITemplateTask } from '../../types/template'; import { patchTemplate } from '../../redux/actions'; type TSetFieldValue = (field: string, value: unknown, shouldValidate?: boolean) => void; @@ -17,6 +17,10 @@ interface ITemplateFieldContextValue { interface ITemplatePersistContextValue { consumePendingChanges(): Partial; + /** Clears a revert snapshot after an explicit `patchTemplate` succeeds. */ + confirmConsumedChanges(): void; + /** Restores the persist baseline when an explicit `patchTemplate` fails. */ + revertConsumedChanges(): void; } const TemplateFieldContext = createContext(null); @@ -56,6 +60,37 @@ export function useTemplatePersist(): ITemplatePersistContextValue { return ctx; } +function mergePreservedTasks( + incomingTasks: ITemplateTask[], + preservedTasks: ITemplateTask[], +): ITemplateTask[] { + return preservedTasks.map((task) => { + const incoming = incomingTasks.find((candidate) => candidate.uuid === task.uuid); + + if (!incoming || task === incoming) { + return task; + } + + const onlyAncestorsDiffer = (Object.keys(task) as (keyof ITemplateTask)[]).every( + (key) => key === 'ancestors' || task[key] === incoming[key], + ); + + return onlyAncestorsDiffer ? { ...task, ancestors: incoming.ancestors } : task; + }); +} + +function getChangedFields(previous: ITemplate, next: ITemplate): Partial { + const changedFields: Partial = {}; + + (Object.keys(next) as (keyof ITemplate)[]).forEach((key) => { + if (previous[key] !== next[key]) { + (changedFields[key] as ITemplate[keyof ITemplate]) = next[key]; + } + }); + + return changedFields; +} + /** * Root Formik context for the whole Edit Template page. * @@ -69,32 +104,51 @@ export function useTemplatePersist(): ITemplatePersistContextValue { * callbacks, so both onChange and submit share one save path. */ export function useTemplateForm(initialValues: ITemplate) { + const dirtyRef = useRef(false); + const pendingUserEditsRef = useRef>({}); + const lastSyncedInitialValuesRef = useRef(initialValues); + const persistBaselineSyncRef = useRef<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>(null); + const formik = useFormik({ initialValues, - enableReinitialize: true, + enableReinitialize: false, onSubmit: () => undefined, }); - const dirtyRef = useRef(false); - const initialValuesRef = useRef(initialValues); - - // Formik reinitializes `values` from `initialValues` whenever the Redux - // `template` prop changes (enableReinitialize). That change is NOT a user - // edit, so a dirty flag left set by a prior wrapped setter (e.g. the - // `setFieldValue('isActive', false)` issued by `flushPersist` right before - // its `patchTemplate` dispatch) must be cleared before the persist effect - // runs — otherwise the next flush treats the reinitialized values as a user - // edit and dispatches a `patchTemplate` diffing against an outdated - // `previousValuesRef`. Doing this during render (rather than in an effect) - // guarantees the flag is cleared before the child persist effect fires. - if (initialValuesRef.current !== initialValues) { - initialValuesRef.current = initialValues; - dirtyRef.current = false; + // Redux `template` updates must be merged into Formik without discarding edits + // that still live only in the form (e.g. typed while a save is in flight). + // Wrapped setters accumulate those edits in `pendingUserEditsRef` because a + // Redux snapshot can land in the same render and overwrite Formik state. + if (lastSyncedInitialValuesRef.current !== initialValues) { + const rawPending = dirtyRef.current ? pendingUserEditsRef.current : {}; + + if (Object.keys(rawPending).length > 0) { + let mergedValues: ITemplate = { ...initialValues, ...rawPending }; + + if (rawPending.tasks && initialValues.tasks) { + mergedValues = { + ...mergedValues, + tasks: mergePreservedTasks(initialValues.tasks, rawPending.tasks), + }; + } + + pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); + dirtyRef.current = true; + formik.setValues(mergedValues, false); + persistBaselineSyncRef.current?.(initialValues, mergedValues); + } else { + pendingUserEditsRef.current = {}; + dirtyRef.current = false; + formik.resetForm({ values: initialValues }); + } + + lastSyncedInitialValuesRef.current = initialValues; } const setFieldValue = useCallback( (field, value, shouldValidate) => { dirtyRef.current = true; + pendingUserEditsRef.current = { ...pendingUserEditsRef.current, [field]: value }; formik.setFieldValue(field, value, shouldValidate); }, [formik], @@ -103,12 +157,16 @@ export function useTemplateForm(initialValues: ITemplate) { const setValues = useCallback( (values, shouldValidate) => { dirtyRef.current = true; + pendingUserEditsRef.current = { + ...pendingUserEditsRef.current, + ...getChangedFields(formik.values, values), + }; formik.setValues(values, shouldValidate); }, - [formik], + [formik, formik.values], ); - return { formik, setFieldValue, setValues, dirtyRef }; + return { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef }; } interface ITemplateFormProps { @@ -116,6 +174,8 @@ interface ITemplateFormProps { setFieldValue: TSetFieldValue; setValues: TSetValues; dirtyRef: React.MutableRefObject; + pendingUserEditsRef: React.MutableRefObject>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>; children: React.ReactNode; } @@ -123,7 +183,15 @@ interface ITemplateFormProps { * Bundles the Formik provider, the wrapped-setter context, and the single * persist provider so `TemplateEdit` only has to render ``. */ -export function TemplateForm({ formik, setFieldValue, setValues, dirtyRef, children }: ITemplateFormProps) { +export function TemplateForm({ + formik, + setFieldValue, + setValues, + dirtyRef, + pendingUserEditsRef, + persistBaselineSyncRef, + children, +}: ITemplateFormProps) { const { values } = formik; const fieldContextValue = useMemo( () => ({ values, setFieldValue, setValues }), @@ -133,24 +201,18 @@ export function TemplateForm({ formik, setFieldValue, setValues, dirtyRef, child return ( - {children} + + {children} + ); } -function getChangedFields(previous: ITemplate, next: ITemplate): Partial { - const changedFields: Partial = {}; - - (Object.keys(next) as (keyof ITemplate)[]).forEach((key) => { - if (previous[key] !== next[key]) { - (changedFields[key] as ITemplate[keyof ITemplate]) = next[key]; - } - }); - - return changedFields; -} - // Keep in sync with `patchTemplateSaga` in redux/template/saga.ts. The saga // flips `isActive` to false server-side whenever a non-activation field changes // and only reflects it back to Formik via a Redux `setTemplate` reinitialize. @@ -183,6 +245,8 @@ function shouldDeactivateTemplate( interface ITemplateFormPersistProviderProps { dirtyRef: React.MutableRefObject; + pendingUserEditsRef: React.MutableRefObject>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>; children: React.ReactNode; } @@ -203,36 +267,97 @@ interface ITemplateFormPersistProviderProps { * controls would keep rendering the template as active (no draft warning, no * enable button) until Redux catches up. */ -export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFormPersistProviderProps) { +export function TemplateFormPersistProvider({ + dirtyRef, + pendingUserEditsRef, + persistBaselineSyncRef, + children, +}: ITemplateFormPersistProviderProps) { const { values, setFieldValue } = useFormikContext(); const dispatch = useDispatch(); const previousValuesRef = useRef(values); const valuesRef = useRef(values); const dispatchRef = useRef(dispatch); const dirtyRefRef = useRef(dirtyRef); + const pendingUserEditsRefRef = useRef(pendingUserEditsRef); + const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); const setFieldValueRef = useRef(setFieldValue); valuesRef.current = values; dispatchRef.current = dispatch; dirtyRefRef.current = dirtyRef; + pendingUserEditsRefRef.current = pendingUserEditsRef; + persistBaselineSyncRefRef.current = persistBaselineSyncRef; setFieldValueRef.current = setFieldValue; - const consumePendingChanges = useCallback(() => { + useEffect(() => { + persistBaselineSyncRefRef.current.current = (reduxTemplate, currentValues) => { + const pendingEdits = pendingUserEditsRefRef.current.current; + previousValuesRef.current = { ...currentValues }; + + (Object.keys(pendingEdits) as (keyof ITemplate)[]).forEach((key) => { + (previousValuesRef.current[key] as ITemplate[keyof ITemplate]) = reduxTemplate[key]; + }); + }; + + return () => { + persistBaselineSyncRefRef.current.current = null; + }; + }, []); + + const consumedPendingRef = useRef<{ previousBaseline: ITemplate; isUserEdit: boolean } | null>(null); + + const takePendingChanges = useCallback((trackForRevert = false) => { if (previousValuesRef.current === valuesRef.current) { return {}; } const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); const isUserEdit = dirtyRefRef.current.current; + + if (trackForRevert && isUserEdit && Object.keys(changedFields).length > 0) { + consumedPendingRef.current = { + previousBaseline: previousValuesRef.current, + isUserEdit, + }; + } + previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; + if (isUserEdit && Object.keys(changedFields).length > 0 && !trackForRevert) { + pendingUserEditsRefRef.current.current = {}; + } + return isUserEdit ? changedFields : {}; }, []); + const consumePendingChanges = useCallback(() => takePendingChanges(true), [takePendingChanges]); + + const confirmConsumedChanges = useCallback(() => { + consumedPendingRef.current = null; + pendingUserEditsRefRef.current.current = {}; + }, []); + + const revertConsumedChanges = useCallback(() => { + const consumed = consumedPendingRef.current; + + if (!consumed) { + return; + } + + previousValuesRef.current = consumed.previousBaseline; + dirtyRefRef.current.current = consumed.isUserEdit; + pendingUserEditsRefRef.current.current = getChangedFields( + consumed.previousBaseline, + valuesRef.current, + ); + consumedPendingRef.current = null; + }, []); + const flushPersist = useCallback(() => { const previousValues = previousValuesRef.current; - const changedFields = consumePendingChanges(); + const changedFields = takePendingChanges(); if (Object.keys(changedFields).length > 0) { if (shouldDeactivateTemplate(changedFields, previousValues)) { @@ -241,7 +366,7 @@ export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFor dispatchRef.current(patchTemplate({ changedFields })); } - }, [consumePendingChanges]); + }, [takePendingChanges]); // Registered before the values effect so its cleanup runs first on unmount // and clears the pending diff before the values effect cleanup calls @@ -270,8 +395,8 @@ export function TemplateFormPersistProvider({ dirtyRef, children }: ITemplateFor }, [values, flushPersist]); const persistContextValue = useMemo( - () => ({ consumePendingChanges }), - [consumePendingChanges], + () => ({ consumePendingChanges, confirmConsumedChanges, revertConsumedChanges }), + [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges], ); return ( From 32d055c70312d28cffe7eec343eaaa4a91a6c399 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 18:27:54 +0300 Subject: [PATCH 15/39] 59876 fix(template): resolve nested task paths in pending Formik edits --- .../__tests__/useTemplateForm.test.tsx | 98 +++++++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 56 +++++++++-- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 36e6a1512..be5bcdc8b 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -382,6 +382,64 @@ describe('useTemplateForm reinitialize', () => { expect(hookResult!.formik.values.name).toBe('unsaved edit'); expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); }); + + it('merges nested task field edits instead of storing dotted Formik paths as top-level keys', () => { + const task = makeTask({ name: 'Original Task' }); + const template = makeTemplate({ tasks: [task], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks.0.name', 'Edited Task', false); + }); + + expect(hookResult!.pendingUserEditsRef.current).toEqual({ + tasks: [expect.objectContaining({ name: 'Edited Task' })], + }); + expect(Object.prototype.hasOwnProperty.call(hookResult!.pendingUserEditsRef.current, 'tasks.0.name')).toBe(false); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.tasks[0].name).toBe('Edited Task'); + expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + expect(Object.prototype.hasOwnProperty.call(hookResult!.formik.values, 'tasks.0.name')).toBe(false); + }); + + it('merges whole-task updates via tasks[index] paths into pending edits', () => { + const task = makeTask({ name: 'Original Task', skipForStarter: false }); + const template = makeTemplate({ tasks: [task], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks.0', { ...task, skipForStarter: true }, false); + }); + + expect(hookResult!.pendingUserEditsRef.current).toEqual({ + tasks: [expect.objectContaining({ skipForStarter: true })], + }); + expect(Object.prototype.hasOwnProperty.call(hookResult!.pendingUserEditsRef.current, 'tasks.0')).toBe(false); + }); }); describe('TemplateFormPersistProvider reinitialize', () => { @@ -479,4 +537,44 @@ describe('TemplateFormPersistProvider reinitialize', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + + it('preserves nested task edits and sends valid changedFields after Redux reinitializes', async () => { + const task = makeTask({ name: 'Original Task' }); + const template = makeTemplate({ isActive: true, tasks: [task], dateUpdated: null }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} + />, + ); + + act(() => { + handle!.setFieldValue('tasks.0.name', 'Edited Task', false); + rerender( + { handle = h; }} + />, + ); + }); + + await flushPersist(); + + expect(handle!.values.tasks[0].name).toBe('Edited Task'); + expect(handle!.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + expect(Object.prototype.hasOwnProperty.call(handle!.values, 'tasks.0.name')).toBe(false); + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + tasks: [expect.objectContaining({ name: 'Edited Task' })], + }); + expect(Object.prototype.hasOwnProperty.call( + (patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields, + 'tasks.0.name', + )).toBe(false); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 2bd70b564..940af9321 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -60,6 +60,46 @@ export function useTemplatePersist(): ITemplatePersistContextValue { return ctx; } +function setNestedFieldValue(obj: ITemplate, path: string, value: unknown): ITemplate { + const taskPathMatch = path.match(/^tasks\.(\d+)(?:\.(.+))?$/); + + if (taskPathMatch) { + const index = Number(taskPathMatch[1]); + const taskField = taskPathMatch[2]; + const tasks = [...obj.tasks]; + + if (!taskField) { + tasks[index] = value as ITemplateTask; + } else { + tasks[index] = { + ...tasks[index], + [taskField]: value, + }; + } + + return { ...obj, tasks }; + } + + return { ...obj, [path]: value }; +} + +function applyPendingEdits(initialValues: ITemplate, pendingEdits: Partial): ITemplate { + if (Object.keys(pendingEdits).length === 0) { + return initialValues; + } + + let mergedValues: ITemplate = { ...initialValues, ...pendingEdits }; + + if (pendingEdits.tasks && initialValues.tasks) { + mergedValues = { + ...mergedValues, + tasks: mergePreservedTasks(initialValues.tasks, pendingEdits.tasks), + }; + } + + return mergedValues; +} + function mergePreservedTasks( incomingTasks: ITemplateTask[], preservedTasks: ITemplateTask[], @@ -123,14 +163,7 @@ export function useTemplateForm(initialValues: ITemplate) { const rawPending = dirtyRef.current ? pendingUserEditsRef.current : {}; if (Object.keys(rawPending).length > 0) { - let mergedValues: ITemplate = { ...initialValues, ...rawPending }; - - if (rawPending.tasks && initialValues.tasks) { - mergedValues = { - ...mergedValues, - tasks: mergePreservedTasks(initialValues.tasks, rawPending.tasks), - }; - } + const mergedValues = applyPendingEdits(initialValues, rawPending); pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); dirtyRef.current = true; @@ -148,7 +181,12 @@ export function useTemplateForm(initialValues: ITemplate) { const setFieldValue = useCallback( (field, value, shouldValidate) => { dirtyRef.current = true; - pendingUserEditsRef.current = { ...pendingUserEditsRef.current, [field]: value }; + const nextValues = setNestedFieldValue( + applyPendingEdits(lastSyncedInitialValuesRef.current, pendingUserEditsRef.current), + field, + value, + ); + pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); formik.setFieldValue(field, value, shouldValidate); }, [formik], From 5b1ff14abe44c2a97583e9d8ba3eba1bd29af9c8 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 18:42:18 +0300 Subject: [PATCH 16/39] 59876 fix(template): clean stale references synchronously on tasks/kickoff edits --- .../__tests__/useTemplateForm.test.tsx | 93 ++++++++++++++++++- .../TemplateEdit/useTemplateForm.tsx | 64 +++++++++++-- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index be5bcdc8b..99b8676e1 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -2,12 +2,18 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; -import { ITemplate, ITemplateTask } from '../../../types/template'; +import { + ITemplate, + ITemplateTask, + ETaskPerformerType, +} from '../../../types/template'; +import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../TaskForm/Conditions/types'; import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist } from '../useTemplateForm'; import { patchTemplate } from '../../../redux/actions'; jest.mock('react-redux', () => ({ useDispatch: jest.fn(() => jest.fn()), + connect: () => (component: unknown) => component, })); jest.mock('../../../redux/actions', () => ({ @@ -578,3 +584,88 @@ describe('TemplateFormPersistProvider reinitialize', () => { )).toBe(false); }); }); + +describe('useTemplateForm reference cleanup', () => { + beforeEach(() => { + (patchTemplate as unknown as jest.Mock).mockClear(); + }); + + it('cleans stale condition rules synchronously when kickoff fields are removed', () => { + const template = makeTemplate({ + kickoff: { + description: '', + fields: [ + { apiName: 'valid-field', name: 'Valid', order: 1 } as any, + { apiName: 'removed-field', name: 'Removed', order: 2 } as any, + ], + } as any, + tasks: [ + makeTask({ + conditions: [ + { + apiName: 'cond-1', + order: 1, + action: EConditionAction.StartTask, + rules: [ + { + field: 'valid-field', + operator: EConditionOperators.Equal, + logicOperation: EConditionLogicOperations.And, + predicateApiName: '1', + } as TConditionRule, + { + field: 'removed-field', + operator: EConditionOperators.Equal, + logicOperation: EConditionLogicOperations.And, + predicateApiName: '2', + } as TConditionRule, + ], + }, + ], + }), + ], + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue( + 'kickoff', + { description: '', fields: [{ apiName: 'valid-field', name: 'Valid', order: 1 }] }, + false, + ); + }); + + const rules = handle!.values.tasks[0].conditions[0].rules; + expect(rules).toHaveLength(1); + expect(rules[0].field).toBe('valid-field'); + expect(patchTemplate).not.toHaveBeenCalled(); + }); + + it('cleans stale performer references synchronously when tasks are removed', () => { + const deletedTask = makeTask({ apiName: 'task-deleted', uuid: 'uuid-deleted', number: 1 }); + const remainingTask = makeTask({ + apiName: 'task-2', + uuid: 'uuid-2', + number: 2, + rawPerformers: [ + { type: ETaskPerformerType.Manager, sourceId: 'task-deleted', label: 'Manager', apiName: 'perf-1' } as any, + { type: ETaskPerformerType.User, sourceId: '1', label: 'User', apiName: 'perf-2' } as any, + ], + }); + const template = makeTemplate({ tasks: [deletedTask, remainingTask] }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('tasks', [{ ...remainingTask, number: 1 }], false); + }); + + expect(handle!.values.tasks).toHaveLength(1); + expect(handle!.values.tasks[0].rawPerformers).toHaveLength(1); + expect(handle!.values.tasks[0].rawPerformers[0].apiName).toBe('perf-2'); + expect(patchTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 940af9321..39359ac75 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -3,8 +3,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef } fr import { FormikProvider, useFormik, useFormikContext } from 'formik'; import { useDispatch } from 'react-redux'; -import { ITemplate, ITemplateTask } from '../../types/template'; +import { ITemplate, ITemplateTask, IKickoff } from '../../types/template'; import { patchTemplate } from '../../redux/actions'; +import { cleanTemplateReferences } from '../../utils/template'; type TSetFieldValue = (field: string, value: unknown, shouldValidate?: boolean) => void; type TSetValues = (values: ITemplate, shouldValidate?: boolean) => void; @@ -119,6 +120,46 @@ function mergePreservedTasks( }); } +function getKickoffFieldApiNames(kickoff: IKickoff): string[] { + return (kickoff.fields || []) + .map((field) => field.apiName) + .filter(Boolean) + .sort(); +} + +function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { + const previousNames = getKickoffFieldApiNames(previous); + const nextNames = getKickoffFieldApiNames(next); + + return previousNames.length !== nextNames.length + || previousNames.some((name, index) => name !== nextNames[index]); +} + +function shouldRunReferenceCleanup(field: string, previous: ITemplate, next: ITemplate): boolean { + if (field === 'tasks') { + return true; + } + + if (field === 'kickoff') { + return didKickoffFieldsChange(previous.kickoff, next.kickoff); + } + + return false; +} + +function applyReferenceCleanup(template: ITemplate): ITemplate { + const cleaned = cleanTemplateReferences(template); + const wfNameTemplate = template.wfNameTemplate == null && cleaned.wfNameTemplate === '' + ? template.wfNameTemplate + : cleaned.wfNameTemplate; + + return { + ...template, + tasks: cleaned.tasks, + wfNameTemplate, + }; +} + function getChangedFields(previous: ITemplate, next: ITemplate): Partial { const changedFields: Partial = {}; @@ -181,13 +222,24 @@ export function useTemplateForm(initialValues: ITemplate) { const setFieldValue = useCallback( (field, value, shouldValidate) => { dirtyRef.current = true; - const nextValues = setNestedFieldValue( - applyPendingEdits(lastSyncedInitialValuesRef.current, pendingUserEditsRef.current), - field, - value, + const currentValues = applyPendingEdits( + lastSyncedInitialValuesRef.current, + pendingUserEditsRef.current, ); + let nextValues = setNestedFieldValue(currentValues, field, value); + const runCleanup = shouldRunReferenceCleanup(field, currentValues, nextValues); + + if (runCleanup) { + nextValues = applyReferenceCleanup(nextValues); + } + pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); - formik.setFieldValue(field, value, shouldValidate); + + if (runCleanup) { + formik.setValues(nextValues, shouldValidate); + } else { + formik.setFieldValue(field, value, shouldValidate); + } }, [formik], ); From cb6fe05e0da7c6651ca076dffb43ec92abbdaa32 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 19:08:17 +0300 Subject: [PATCH 17/39] 59876 fix(template): run reference cleanup on nested task field edits --- .../__tests__/useTemplateForm.test.tsx | 30 +++++++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 99b8676e1..71973b03f 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -668,4 +668,34 @@ describe('useTemplateForm reference cleanup', () => { expect(handle!.values.tasks[0].rawPerformers[0].apiName).toBe('perf-2'); expect(patchTemplate).not.toHaveBeenCalled(); }); + + it('cleans stale performer references synchronously when a task output field is removed', () => { + const taskWithOutput = makeTask({ + apiName: 'task-1', + uuid: 'uuid-1', + number: 1, + fields: [{ apiName: 'deleted-output', name: 'Output', order: 1 } as any], + }); + const dependentTask = makeTask({ + apiName: 'task-2', + uuid: 'uuid-2', + number: 2, + rawPerformers: [ + { type: ETaskPerformerType.OutputUser, sourceId: 'deleted-output', label: 'Output', apiName: 'perf-1' } as any, + { type: ETaskPerformerType.User, sourceId: '1', label: 'User', apiName: 'perf-2' } as any, + ], + }); + const template = makeTemplate({ tasks: [taskWithOutput, dependentTask] }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('tasks.0.fields', [], false); + }); + + expect(handle!.values.tasks[1].rawPerformers).toHaveLength(1); + expect(handle!.values.tasks[1].rawPerformers[0].apiName).toBe('perf-2'); + expect(patchTemplate).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 39359ac75..d227316e3 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -136,7 +136,7 @@ function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { } function shouldRunReferenceCleanup(field: string, previous: ITemplate, next: ITemplate): boolean { - if (field === 'tasks') { + if (field === 'tasks' || field.startsWith('tasks.')) { return true; } From 92f8499447bc10bab133a3a4a3813aedb5cb78ec Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 19:37:48 +0300 Subject: [PATCH 18/39] 59876 fix(template): preserve flushed Formik edits during reference cleanup --- .../__tests__/useTemplateForm.test.tsx | 91 +++++++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 13 ++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 71973b03f..2183e394f 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -669,6 +669,97 @@ describe('useTemplateForm reference cleanup', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + it('preserves flushed Formik edits when kickoff cleanup runs before Redux reinitializes', async () => { + const template = makeTemplate({ + isActive: true, + description: 'old', + kickoff: { + description: '', + fields: [ + { apiName: 'valid-field', name: 'Valid', order: 1 } as any, + { apiName: 'removed-field', name: 'Removed', order: 2 } as any, + ], + } as any, + tasks: [ + makeTask({ + conditions: [ + { + apiName: 'cond-1', + order: 1, + action: EConditionAction.StartTask, + rules: [ + { + field: 'removed-field', + operator: EConditionOperators.Equal, + logicOperation: EConditionLogicOperations.And, + predicateApiName: '1', + } as TConditionRule, + ], + }, + ], + }), + ], + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'saved edit', false); + }); + await flushPersist(); + + act(() => { + handle!.setFieldValue('name', 'unsaved edit', false); + handle!.setFieldValue( + 'kickoff', + { description: '', fields: [{ apiName: 'valid-field', name: 'Valid', order: 1 }] }, + false, + ); + }); + + expect(handle!.values.description).toBe('saved edit'); + expect(handle!.values.name).toBe('unsaved edit'); + expect(handle!.values.isActive).toBe(false); + expect(handle!.values.tasks[0].conditions[0].rules).toHaveLength(0); + }); + + it('preserves flushed Formik edits when full tasks replacement runs before Redux reinitializes', async () => { + const deletedTask = makeTask({ apiName: 'task-deleted', uuid: 'uuid-deleted', number: 1 }); + const remainingTask = makeTask({ + apiName: 'task-2', + uuid: 'uuid-2', + number: 2, + rawPerformers: [ + { type: ETaskPerformerType.Manager, sourceId: 'task-deleted', label: 'Manager', apiName: 'perf-1' } as any, + ], + }); + const template = makeTemplate({ + isActive: true, + description: 'old', + tasks: [deletedTask, remainingTask], + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'saved edit', false); + }); + await flushPersist(); + + act(() => { + handle!.setFieldValue('name', 'unsaved edit', false); + handle!.setFieldValue('tasks', [{ ...remainingTask, number: 1 }], false); + }); + + expect(handle!.values.description).toBe('saved edit'); + expect(handle!.values.name).toBe('unsaved edit'); + expect(handle!.values.isActive).toBe(false); + expect(handle!.values.tasks).toHaveLength(1); + expect(handle!.values.tasks[0].rawPerformers).toHaveLength(0); + }); + it('cleans stale performer references synchronously when a task output field is removed', () => { const taskWithOutput = makeTask({ apiName: 'task-1', diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index d227316e3..934da6215 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -101,6 +101,14 @@ function applyPendingEdits(initialValues: ITemplate, pendingEdits: Partial): ITemplate { + if (Object.keys(pendingEdits).length === 0) { + return formikValues; + } + + return applyPendingEdits(formikValues, pendingEdits); +} + function mergePreservedTasks( incomingTasks: ITemplateTask[], preservedTasks: ITemplateTask[], @@ -222,10 +230,7 @@ export function useTemplateForm(initialValues: ITemplate) { const setFieldValue = useCallback( (field, value, shouldValidate) => { dirtyRef.current = true; - const currentValues = applyPendingEdits( - lastSyncedInitialValuesRef.current, - pendingUserEditsRef.current, - ); + const currentValues = overlayPendingEdits(formik.values, pendingUserEditsRef.current); let nextValues = setNestedFieldValue(currentValues, field, value); const runCleanup = shouldRunReferenceCleanup(field, currentValues, nextValues); From 94befcdac617be4ec75210fbed3242edbba6de9e Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 19:47:35 +0300 Subject: [PATCH 19/39] 59876 fix(template): deactivate active templates synchronously on edit --- .../__tests__/useTemplateForm.test.tsx | 15 ++++-- .../TemplateEdit/useTemplateForm.tsx | 51 +++++++++++-------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 2183e394f..f487301c9 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -138,9 +138,9 @@ describe('TemplateFormPersistProvider deactivation', () => { handle!.setFieldValue('description', 'new description', false); }); - await flushPersist(); - expect(handle!.values.isActive).toBe(false); + + await flushPersist(); expect(patchTemplate).toHaveBeenCalledTimes(1); }); @@ -213,7 +213,7 @@ describe('TemplateFormPersistProvider deactivation', () => { }); await flushPersist(); - expect(pendingChanges).toEqual({ owners }); + expect(pendingChanges).toEqual({ owners, isActive: false }); expect(patchTemplate).not.toHaveBeenCalled(); }); @@ -235,7 +235,10 @@ describe('TemplateFormPersistProvider deactivation', () => { await flushPersist(); expect(patchTemplate).toHaveBeenCalledTimes(1); - expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ owners }); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + owners, + isActive: false, + }); }); it('does not restore the persist baseline after an explicit submit succeeds', async () => { @@ -273,6 +276,7 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).toHaveBeenCalledTimes(1); expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ description: 'second edit', + isActive: false, }); }); @@ -406,6 +410,7 @@ describe('useTemplateForm reinitialize', () => { }); expect(hookResult!.pendingUserEditsRef.current).toEqual({ + isActive: false, tasks: [expect.objectContaining({ name: 'Edited Task' })], }); expect(Object.prototype.hasOwnProperty.call(hookResult!.pendingUserEditsRef.current, 'tasks.0.name')).toBe(false); @@ -442,6 +447,7 @@ describe('useTemplateForm reinitialize', () => { }); expect(hookResult!.pendingUserEditsRef.current).toEqual({ + isActive: false, tasks: [expect.objectContaining({ skipForStarter: true })], }); expect(Object.prototype.hasOwnProperty.call(hookResult!.pendingUserEditsRef.current, 'tasks.0')).toBe(false); @@ -576,6 +582,7 @@ describe('TemplateFormPersistProvider reinitialize', () => { expect(Object.prototype.hasOwnProperty.call(handle!.values, 'tasks.0.name')).toBe(false); expect(patchTemplate).toHaveBeenCalledTimes(1); expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + isActive: false, tasks: [expect.objectContaining({ name: 'Edited Task' })], }); expect(Object.prototype.hasOwnProperty.call( diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 934da6215..088412389 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -238,9 +238,10 @@ export function useTemplateForm(initialValues: ITemplate) { nextValues = applyReferenceCleanup(nextValues); } + nextValues = applyImmediateDeactivation(currentValues, nextValues); pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); - if (runCleanup) { + if (runCleanup || nextValues.isActive !== currentValues.isActive) { formik.setValues(nextValues, shouldValidate); } else { formik.setFieldValue(field, value, shouldValidate); @@ -252,11 +253,10 @@ export function useTemplateForm(initialValues: ITemplate) { const setValues = useCallback( (values, shouldValidate) => { dirtyRef.current = true; - pendingUserEditsRef.current = { - ...pendingUserEditsRef.current, - ...getChangedFields(formik.values, values), - }; - formik.setValues(values, shouldValidate); + const currentValues = overlayPendingEdits(formik.values, pendingUserEditsRef.current); + const nextValues = applyImmediateDeactivation(currentValues, values); + pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); + formik.setValues(nextValues, shouldValidate); }, [formik, formik.values], ); @@ -311,9 +311,9 @@ export function TemplateForm({ // Keep in sync with `patchTemplateSaga` in redux/template/saga.ts. The saga // flips `isActive` to false server-side whenever a non-activation field changes // and only reflects it back to Formik via a Redux `setTemplate` reinitialize. -// Mirroring that decision here lets Formik (and the controls that read from it -// via `useTemplateField`) deactivate immediately, instead of staying "active" -// through the persist flush + saga + reinitialize round-trip. +// `applyImmediateDeactivation` mirrors that decision in the wrapped setters so +// Formik (and controls like `RouteLeavingGuard` that read from it) deactivate +// on the same tick as the edit instead of waiting for the deferred persist flush. const NON_DEACTIVATIVE_FIELDS: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; function shouldDeactivateTemplate( @@ -338,6 +338,20 @@ function shouldDeactivateTemplate( return shouldDeactivate; } +function applyImmediateDeactivation(previous: ITemplate, next: ITemplate): ITemplate { + if (!previous.isActive) { + return next; + } + + const changedFields = getChangedFields(previous, next); + + if (shouldDeactivateTemplate(changedFields, previous)) { + return { ...next, isActive: false }; + } + + return next; +} + interface ITemplateFormPersistProviderProps { dirtyRef: React.MutableRefObject; pendingUserEditsRef: React.MutableRefObject>; @@ -356,11 +370,11 @@ interface ITemplateFormPersistProviderProps { * owners normalization, ...) update `previousValuesRef` but never dispatch, so * they cannot cause a save loop. * - * When a user edit touches a non-activation field, we also flip `isActive` to - * false in Formik right away — the saga makes the same call server-side, but - * only reinitializes Formik after the save round-trip, so without this the - * controls would keep rendering the template as active (no draft warning, no - * enable button) until Redux catches up. + * When a user edit touches a non-activation field, the wrapped `setFieldValue`/ + * `setValues` flip `isActive` to false in Formik synchronously (see + * `applyImmediateDeactivation`) so controls like `RouteLeavingGuard` react + * immediately. The saga makes the same call server-side but only reinitializes + * Formik after the save round-trip. */ export function TemplateFormPersistProvider({ dirtyRef, @@ -368,7 +382,7 @@ export function TemplateFormPersistProvider({ persistBaselineSyncRef, children, }: ITemplateFormPersistProviderProps) { - const { values, setFieldValue } = useFormikContext(); + const { values } = useFormikContext(); const dispatch = useDispatch(); const previousValuesRef = useRef(values); const valuesRef = useRef(values); @@ -376,14 +390,12 @@ export function TemplateFormPersistProvider({ const dirtyRefRef = useRef(dirtyRef); const pendingUserEditsRefRef = useRef(pendingUserEditsRef); const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); - const setFieldValueRef = useRef(setFieldValue); valuesRef.current = values; dispatchRef.current = dispatch; dirtyRefRef.current = dirtyRef; pendingUserEditsRefRef.current = pendingUserEditsRef; persistBaselineSyncRefRef.current = persistBaselineSyncRef; - setFieldValueRef.current = setFieldValue; useEffect(() => { persistBaselineSyncRefRef.current.current = (reduxTemplate, currentValues) => { @@ -451,14 +463,9 @@ export function TemplateFormPersistProvider({ }, []); const flushPersist = useCallback(() => { - const previousValues = previousValuesRef.current; const changedFields = takePendingChanges(); if (Object.keys(changedFields).length > 0) { - if (shouldDeactivateTemplate(changedFields, previousValues)) { - setFieldValueRef.current('isActive', false, false); - } - dispatchRef.current(patchTemplate({ changedFields })); } }, [takePendingChanges]); From 481e26c628f625c717383750991f87ec6125d594 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 20:58:41 +0300 Subject: [PATCH 20/39] 59876 fix(template): prevent autosave on unmount after discarding edits --- .../TemplateEdit/__tests__/useTemplateForm.test.tsx | 4 +++- .../components/TemplateEdit/useTemplateForm.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index f487301c9..bc3af0c6d 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -320,7 +320,9 @@ describe('TemplateFormPersistProvider deactivation', () => { handle!.setFieldValue('description', 'discarded edit', false); }); - unmount(); + act(() => { + unmount(); + }); await flushPersist(); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 088412389..96193f032 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { FormikProvider, useFormik, useFormikContext } from 'formik'; import { useDispatch } from 'react-redux'; @@ -470,11 +470,11 @@ export function TemplateFormPersistProvider({ } }, [takePendingChanges]); - // Registered before the values effect so its cleanup runs first on unmount - // and clears the pending diff before the values effect cleanup calls - // `flushPersist`, preventing discarded edits from being dispatched when the - // user leaves the page (e.g. "Discard changes" + navigate away). - useEffect(() => { + // useLayoutEffect so this cleanup runs before passive effect cleanups on + // unmount. The values effect cleanup calls `flushPersist`; clearing the + // pending diff here first prevents discarded edits from being dispatched when + // the user leaves the page (e.g. "Discard changes" + navigate away). + useLayoutEffect(() => { return () => { previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; From 35942861348dcb5b181339e3bde312fc0c2a77db Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Wed, 1 Jul 2026 21:35:27 +0300 Subject: [PATCH 21/39] 59876 fix(template): flush unmount edits and preserve server-added tasks --- .../TemplateControlls/TemplateControlls.tsx | 15 ++++- .../__tests__/useTemplateForm.test.tsx | 65 ++++++++++++++++++- .../TemplateEdit/useTemplateForm.tsx | 56 ++++++++++------ 3 files changed, 110 insertions(+), 26 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index ef71c448b..0259ba01b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -60,7 +60,12 @@ export function TemplateControlls({ const { formatMessage } = intl; const dispatch = useDispatch(); const { values: template, setFieldValue } = useTemplateField(); - const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges } = useTemplatePersist(); + const { + consumePendingChanges, + confirmConsumedChanges, + revertConsumedChanges, + abandonPendingChanges, + } = useTemplatePersist(); const templateStatus = useSelector(getTemplateStatus); const isSubscribed = useSelector(getIsUserSubsribed); const billingPlan = useSelector(getSubscriptionPlan); @@ -223,7 +228,13 @@ export function TemplateControlls({ diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index bc3af0c6d..344b5e64e 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -72,6 +72,7 @@ interface ISpyHandle { consumePendingChanges: () => Partial; confirmConsumedChanges: () => void; revertConsumedChanges: () => void; + abandonPendingChanges: () => void; } // Drain the persist provider's `setTimeout(0)` flush plus the follow-up tick it @@ -94,8 +95,8 @@ function TemplateFormHarness({ const Spy: React.FC = () => { const { values, setFieldValue: contextSetFieldValue } = useTemplateField(); - const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges } = useTemplatePersist(); - spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges, confirmConsumedChanges, revertConsumedChanges }); + const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges } = useTemplatePersist(); + spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges }); return null; }; @@ -308,7 +309,7 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).toHaveBeenCalledTimes(1); }); - it('does not dispatch patchTemplate on unmount when pending edits were not flushed', async () => { + it('does not dispatch patchTemplate on unmount after abandoning pending edits', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; @@ -318,6 +319,7 @@ describe('TemplateFormPersistProvider deactivation', () => { act(() => { handle!.setFieldValue('description', 'discarded edit', false); + handle!.abandonPendingChanges(); }); act(() => { @@ -329,6 +331,29 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + it('dispatches patchTemplate on unmount when pending edits were not flushed', async () => { + const template = makeTemplate({ isActive: true, description: 'old' }); + let handle: ISpyHandle | null = null; + + const { unmount } = render( + { handle = h; }} />, + ); + + act(() => { + handle!.setFieldValue('description', 'unsaved edit', false); + }); + + act(() => { + unmount(); + }); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + description: 'unsaved edit', + isActive: false, + }); + }); + it('still persists a second edit when the first triggers an isActive deactivation', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; @@ -454,6 +479,40 @@ describe('useTemplateForm reinitialize', () => { }); expect(Object.prototype.hasOwnProperty.call(hookResult!.pendingUserEditsRef.current, 'tasks.0')).toBe(false); }); + + it('preserves incoming-only tasks when Redux reinitializes during pending task edits', () => { + const task1 = makeTask({ apiName: 'task-1', uuid: 'uuid-1', number: 1, name: 'Original' }); + const task2 = makeTask({ apiName: 'task-2', uuid: 'uuid-2', number: 2, name: 'Server Added' }); + const template = makeTemplate({ tasks: [task1], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks.0.name', 'Edited Task', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.tasks).toHaveLength(2); + expect(hookResult!.formik.values.tasks[0].name).toBe('Edited Task'); + expect(hookResult!.formik.values.tasks[1].name).toBe('Server Added'); + expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + }); }); describe('TemplateFormPersistProvider reinitialize', () => { diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 96193f032..3bf9a6f21 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { FormikProvider, useFormik, useFormikContext } from 'formik'; import { useDispatch } from 'react-redux'; @@ -22,6 +22,8 @@ interface ITemplatePersistContextValue { confirmConsumedChanges(): void; /** Restores the persist baseline when an explicit `patchTemplate` fails. */ revertConsumedChanges(): void; + /** Drops uncommitted edits without dispatching (e.g. after "Discard changes"). */ + abandonPendingChanges(): void; } const TemplateFieldContext = createContext(null); @@ -113,19 +115,26 @@ function mergePreservedTasks( incomingTasks: ITemplateTask[], preservedTasks: ITemplateTask[], ): ITemplateTask[] { - return preservedTasks.map((task) => { - const incoming = incomingTasks.find((candidate) => candidate.uuid === task.uuid); + const preservedByUuid = new Map(preservedTasks.map((task) => [task.uuid, task])); - if (!incoming || task === incoming) { - return task; + const mergedIncoming = incomingTasks.map((incoming) => { + const preserved = preservedByUuid.get(incoming.uuid); + + if (!preserved || preserved === incoming) { + return preserved || incoming; } - const onlyAncestorsDiffer = (Object.keys(task) as (keyof ITemplateTask)[]).every( - (key) => key === 'ancestors' || task[key] === incoming[key], + const onlyAncestorsDiffer = (Object.keys(preserved) as (keyof ITemplateTask)[]).every( + (key) => key === 'ancestors' || preserved[key] === incoming[key], ); - return onlyAncestorsDiffer ? { ...task, ancestors: incoming.ancestors } : task; + return onlyAncestorsDiffer ? { ...preserved, ancestors: incoming.ancestors } : preserved; }); + + const incomingUuids = new Set(incomingTasks.map((task) => task.uuid)); + const preservedOnlyTasks = preservedTasks.filter((task) => !incomingUuids.has(task.uuid)); + + return [...mergedIncoming, ...preservedOnlyTasks]; } function getKickoffFieldApiNames(kickoff: IKickoff): string[] { @@ -375,6 +384,10 @@ interface ITemplateFormPersistProviderProps { * `applyImmediateDeactivation`) so controls like `RouteLeavingGuard` react * immediately. The saga makes the same call server-side but only reinitializes * Formik after the save round-trip. + * + * On unmount the values effect cleanup calls `flushPersist` so edits made just + * before leaving the page are not lost. Call `abandonPendingChanges` before + * navigating away after "Discard changes" so those edits are not re-dispatched. */ export function TemplateFormPersistProvider({ dirtyRef, @@ -462,6 +475,13 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; }, []); + const abandonPendingChanges = useCallback(() => { + previousValuesRef.current = valuesRef.current; + dirtyRefRef.current.current = false; + pendingUserEditsRefRef.current.current = {}; + consumedPendingRef.current = null; + }, []); + const flushPersist = useCallback(() => { const changedFields = takePendingChanges(); @@ -470,17 +490,6 @@ export function TemplateFormPersistProvider({ } }, [takePendingChanges]); - // useLayoutEffect so this cleanup runs before passive effect cleanups on - // unmount. The values effect cleanup calls `flushPersist`; clearing the - // pending diff here first prevents discarded edits from being dispatched when - // the user leaves the page (e.g. "Discard changes" + navigate away). - useLayoutEffect(() => { - return () => { - previousValuesRef.current = valuesRef.current; - dirtyRefRef.current.current = false; - }; - }, []); - useEffect(() => { if (previousValuesRef.current === values) { return undefined; @@ -497,8 +506,13 @@ export function TemplateFormPersistProvider({ }, [values, flushPersist]); const persistContextValue = useMemo( - () => ({ consumePendingChanges, confirmConsumedChanges, revertConsumedChanges }), - [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges], + () => ({ + consumePendingChanges, + confirmConsumedChanges, + revertConsumedChanges, + abandonPendingChanges, + }), + [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges], ); return ( From 0f894d9695c78c4b6e6811cca4e52ab60d23ff9f Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 14:08:44 +0300 Subject: [PATCH 22/39] 59876 fix(template): fix task merge on reinit and autosave after failed activate --- .../__tests__/useTemplateForm.test.tsx | 69 ++++++++++++++++- .../TemplateEdit/useTemplateForm.tsx | 76 +++++++++++++------ 2 files changed, 119 insertions(+), 26 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 344b5e64e..fd01b7975 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -218,7 +218,7 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); - it('restores the persist baseline when an explicit submit fails after consume', async () => { + it('restores the persist baseline and re-queues autosave when an explicit submit fails after consume', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; const owners = [{ id: 1, role: 'owner' }] as any; @@ -513,6 +513,73 @@ describe('useTemplateForm reinitialize', () => { expect(hookResult!.formik.values.tasks[1].name).toBe('Server Added'); expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); }); + + it('does not restore locally deleted tasks when Redux reinitializes before the deletion is saved', () => { + const task1 = makeTask({ apiName: 'task-1', uuid: 'uuid-1', number: 1, name: 'Task 1' }); + const task2 = makeTask({ apiName: 'task-2', uuid: 'uuid-2', number: 2, name: 'Task 2' }); + const template = makeTemplate({ tasks: [task1, task2], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks', [task1], false); + }); + + expect(hookResult!.formik.values.tasks).toHaveLength(1); + expect(hookResult!.formik.values.tasks[0].uuid).toBe('uuid-1'); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.tasks).toHaveLength(1); + expect(hookResult!.formik.values.tasks[0].uuid).toBe('uuid-1'); + expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + }); + + it('preserves locally added tasks when Redux reinitializes before the addition is saved', () => { + const task1 = makeTask({ apiName: 'task-1', uuid: 'uuid-1', number: 1, name: 'Task 1' }); + const localTask = makeTask({ apiName: 'task-local', uuid: 'uuid-local', number: 2, name: 'Local Task' }); + const template = makeTemplate({ tasks: [task1], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks', [task1, localTask], false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.tasks).toHaveLength(2); + expect(hookResult!.formik.values.tasks.map((task) => task.uuid)).toEqual(['uuid-1', 'uuid-local']); + expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + }); }); describe('TemplateFormPersistProvider reinitialize', () => { diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 3bf9a6f21..9d5190cae 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -20,7 +20,7 @@ interface ITemplatePersistContextValue { consumePendingChanges(): Partial; /** Clears a revert snapshot after an explicit `patchTemplate` succeeds. */ confirmConsumedChanges(): void; - /** Restores the persist baseline when an explicit `patchTemplate` fails. */ + /** Restores the persist baseline and re-queues autosave when an explicit `patchTemplate` fails. */ revertConsumedChanges(): void; /** Drops uncommitted edits without dispatching (e.g. after "Discard changes"). */ abandonPendingChanges(): void; @@ -86,7 +86,11 @@ function setNestedFieldValue(obj: ITemplate, path: string, value: unknown): ITem return { ...obj, [path]: value }; } -function applyPendingEdits(initialValues: ITemplate, pendingEdits: Partial): ITemplate { +function applyPendingEdits( + initialValues: ITemplate, + pendingEdits: Partial, + baselineValues: ITemplate, +): ITemplate { if (Object.keys(pendingEdits).length === 0) { return initialValues; } @@ -96,32 +100,38 @@ function applyPendingEdits(initialValues: ITemplate, pendingEdits: Partial): ITemplate { +function overlayPendingEdits( + formikValues: ITemplate, + pendingEdits: Partial, + baselineValues: ITemplate, +): ITemplate { if (Object.keys(pendingEdits).length === 0) { return formikValues; } - return applyPendingEdits(formikValues, pendingEdits); + return applyPendingEdits(formikValues, pendingEdits, baselineValues); } function mergePreservedTasks( incomingTasks: ITemplateTask[], preservedTasks: ITemplateTask[], + baselineTasks: ITemplateTask[], ): ITemplateTask[] { - const preservedByUuid = new Map(preservedTasks.map((task) => [task.uuid, task])); + const incomingByUuid = new Map(incomingTasks.map((task) => [task.uuid, task])); + const baselineUuids = new Set(baselineTasks.map((task) => task.uuid)); - const mergedIncoming = incomingTasks.map((incoming) => { - const preserved = preservedByUuid.get(incoming.uuid); + const mergedFromPreserved = preservedTasks.map((preserved) => { + const incoming = incomingByUuid.get(preserved.uuid); - if (!preserved || preserved === incoming) { - return preserved || incoming; + if (!incoming || preserved === incoming) { + return preserved; } const onlyAncestorsDiffer = (Object.keys(preserved) as (keyof ITemplateTask)[]).every( @@ -131,10 +141,12 @@ function mergePreservedTasks( return onlyAncestorsDiffer ? { ...preserved, ancestors: incoming.ancestors } : preserved; }); - const incomingUuids = new Set(incomingTasks.map((task) => task.uuid)); - const preservedOnlyTasks = preservedTasks.filter((task) => !incomingUuids.has(task.uuid)); + const preservedUuids = new Set(preservedTasks.map((task) => task.uuid)); + const serverAddedTasks = incomingTasks.filter( + (task) => !baselineUuids.has(task.uuid) && !preservedUuids.has(task.uuid), + ); - return [...mergedIncoming, ...preservedOnlyTasks]; + return [...mergedFromPreserved, ...serverAddedTasks]; } function getKickoffFieldApiNames(kickoff: IKickoff): string[] { @@ -221,7 +233,7 @@ export function useTemplateForm(initialValues: ITemplate) { const rawPending = dirtyRef.current ? pendingUserEditsRef.current : {}; if (Object.keys(rawPending).length > 0) { - const mergedValues = applyPendingEdits(initialValues, rawPending); + const mergedValues = applyPendingEdits(initialValues, rawPending, lastSyncedInitialValuesRef.current); pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); dirtyRef.current = true; @@ -239,7 +251,11 @@ export function useTemplateForm(initialValues: ITemplate) { const setFieldValue = useCallback( (field, value, shouldValidate) => { dirtyRef.current = true; - const currentValues = overlayPendingEdits(formik.values, pendingUserEditsRef.current); + const currentValues = overlayPendingEdits( + formik.values, + pendingUserEditsRef.current, + lastSyncedInitialValuesRef.current, + ); let nextValues = setNestedFieldValue(currentValues, field, value); const runCleanup = shouldRunReferenceCleanup(field, currentValues, nextValues); @@ -262,7 +278,11 @@ export function useTemplateForm(initialValues: ITemplate) { const setValues = useCallback( (values, shouldValidate) => { dirtyRef.current = true; - const currentValues = overlayPendingEdits(formik.values, pendingUserEditsRef.current); + const currentValues = overlayPendingEdits( + formik.values, + pendingUserEditsRef.current, + lastSyncedInitialValuesRef.current, + ); const nextValues = applyImmediateDeactivation(currentValues, values); pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); formik.setValues(nextValues, shouldValidate); @@ -459,6 +479,14 @@ export function TemplateFormPersistProvider({ pendingUserEditsRefRef.current.current = {}; }, []); + const flushPersist = useCallback(() => { + const changedFields = takePendingChanges(); + + if (Object.keys(changedFields).length > 0) { + dispatchRef.current(patchTemplate({ changedFields })); + } + }, [takePendingChanges]); + const revertConsumedChanges = useCallback(() => { const consumed = consumedPendingRef.current; @@ -473,7 +501,13 @@ export function TemplateFormPersistProvider({ valuesRef.current, ); consumedPendingRef.current = null; - }, []); + + // Restoring the baseline does not change Formik `values`, so the values + // effect won't re-run and re-dispatch. This runs from a saga `onFailed` + // callback (outside React render), so it's safe to persist the restored + // edits right away instead of stranding them. + flushPersist(); + }, [flushPersist]); const abandonPendingChanges = useCallback(() => { previousValuesRef.current = valuesRef.current; @@ -482,14 +516,6 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; }, []); - const flushPersist = useCallback(() => { - const changedFields = takePendingChanges(); - - if (Object.keys(changedFields).length > 0) { - dispatchRef.current(patchTemplate({ changedFields })); - } - }, [takePendingChanges]); - useEffect(() => { if (previousValuesRef.current === values) { return undefined; From 169a009bfbe4be3a5f709d86fe59d2852d934b47 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 14:27:34 +0300 Subject: [PATCH 23/39] 59876 fix(template): interleave server tasks on reinit and fix persist baseline --- .../__tests__/useTemplateForm.test.tsx | 86 +++++++++++++++++++ .../TemplateEdit/useTemplateForm.tsx | 44 +++++++--- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index fd01b7975..bf83b6fe5 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -514,6 +514,45 @@ describe('useTemplateForm reinitialize', () => { expect(hookResult!.formik.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); }); + it('interleaves server-added tasks by number when Redux reinitializes during pending task edits', () => { + const task1 = makeTask({ apiName: 'task-1', uuid: 'uuid-1', number: 1, name: 'Task 1' }); + const task3 = makeTask({ apiName: 'task-3', uuid: 'uuid-3', number: 3, name: 'Task 3' }); + const task2 = makeTask({ apiName: 'task-2', uuid: 'uuid-2', number: 2, name: 'Server Inserted' }); + const template = makeTemplate({ tasks: [task1, task3], dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('tasks.0.name', 'Edited Task 1', false); + hookResult!.setFieldValue('tasks.1.name', 'Edited Task 3', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.tasks).toHaveLength(3); + expect(hookResult!.formik.values.tasks.map((task) => task.name)).toEqual([ + 'Edited Task 1', + 'Server Inserted', + 'Edited Task 3', + ]); + expect(hookResult!.formik.values.tasks.map((task) => task.number)).toEqual([1, 2, 3]); + }); + it('does not restore locally deleted tasks when Redux reinitializes before the deletion is saved', () => { const task1 = makeTask({ apiName: 'task-1', uuid: 'uuid-1', number: 1, name: 'Task 1' }); const task2 = makeTask({ apiName: 'task-2', uuid: 'uuid-2', number: 2, name: 'Task 2' }); @@ -678,6 +717,53 @@ describe('TemplateFormPersistProvider reinitialize', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + it('does not re-dispatch externally reinitialized fields when a different edit is pending', async () => { + const template = makeTemplate({ + isActive: true, + name: 'Original', + dateUpdated: null, + owners: [], + }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} + />, + ); + + act(() => { + handle!.setFieldValue('name', 'unsaved edit', false); + }); + + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + rerender( + { handle = h; }} + />, + ); + }); + + await flushPersist(); + + expect(handle!.values.name).toBe('unsaved edit'); + expect(handle!.values.dateUpdated).toBe('2026-07-01T00:00:00Z'); + expect(handle!.values.owners).toEqual([{ id: 1, role: 'owner' }]); + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + name: 'unsaved edit', + isActive: false, + }); + }); + it('preserves nested task edits and sends valid changedFields after Redux reinitializes', async () => { const task = makeTask({ name: 'Original Task' }); const template = makeTemplate({ isActive: true, tasks: [task], dateUpdated: null }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx index 9d5190cae..bb4a6b8c8 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx @@ -146,7 +146,29 @@ function mergePreservedTasks( (task) => !baselineUuids.has(task.uuid) && !preservedUuids.has(task.uuid), ); - return [...mergedFromPreserved, ...serverAddedTasks]; + if (serverAddedTasks.length === 0) { + return mergedFromPreserved; + } + + const sortedServerAdded = [...serverAddedTasks].sort((a, b) => a.number - b.number); + const preservedOrderNumber = (task: ITemplateTask) => + incomingByUuid.get(task.uuid)?.number ?? task.number; + + const insertState = { serverIndex: 0 }; + + const interleaved = mergedFromPreserved.reduce((result, preserved) => { + while ( + insertState.serverIndex < sortedServerAdded.length + && sortedServerAdded[insertState.serverIndex].number < preservedOrderNumber(preserved) + ) { + result.push(sortedServerAdded[insertState.serverIndex]); + insertState.serverIndex += 1; + } + result.push(preserved); + return result; + }, []); + + return [...interleaved, ...sortedServerAdded.slice(insertState.serverIndex)]; } function getKickoffFieldApiNames(kickoff: IKickoff): string[] { @@ -217,7 +239,7 @@ export function useTemplateForm(initialValues: ITemplate) { const dirtyRef = useRef(false); const pendingUserEditsRef = useRef>({}); const lastSyncedInitialValuesRef = useRef(initialValues); - const persistBaselineSyncRef = useRef<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>(null); + const persistBaselineSyncRef = useRef<((reduxTemplate: ITemplate) => void) | null>(null); const formik = useFormik({ initialValues, @@ -238,7 +260,7 @@ export function useTemplateForm(initialValues: ITemplate) { pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); dirtyRef.current = true; formik.setValues(mergedValues, false); - persistBaselineSyncRef.current?.(initialValues, mergedValues); + persistBaselineSyncRef.current?.(initialValues); } else { pendingUserEditsRef.current = {}; dirtyRef.current = false; @@ -299,7 +321,7 @@ interface ITemplateFormProps { setValues: TSetValues; dirtyRef: React.MutableRefObject; pendingUserEditsRef: React.MutableRefObject>; - persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; children: React.ReactNode; } @@ -384,7 +406,7 @@ function applyImmediateDeactivation(previous: ITemplate, next: ITemplate): ITemp interface ITemplateFormPersistProviderProps { dirtyRef: React.MutableRefObject; pendingUserEditsRef: React.MutableRefObject>; - persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate, currentValues: ITemplate) => void) | null>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; children: React.ReactNode; } @@ -431,13 +453,11 @@ export function TemplateFormPersistProvider({ persistBaselineSyncRefRef.current = persistBaselineSyncRef; useEffect(() => { - persistBaselineSyncRefRef.current.current = (reduxTemplate, currentValues) => { - const pendingEdits = pendingUserEditsRefRef.current.current; - previousValuesRef.current = { ...currentValues }; - - (Object.keys(pendingEdits) as (keyof ITemplate)[]).forEach((key) => { - (previousValuesRef.current[key] as ITemplate[keyof ITemplate]) = reduxTemplate[key]; - }); + persistBaselineSyncRefRef.current.current = (reduxTemplate) => { + // Baseline must mirror the latest Redux snapshot for every field so + // server-stamped or normalized values (dateUpdated, owners, ...) are not + // diffed as user edits on the next flush. + previousValuesRef.current = { ...reduxTemplate }; }; return () => { From 1a75351d25bcb59b8e1e29fddc21d5ea7552ab48 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 15:05:41 +0300 Subject: [PATCH 24/39] 59876 fix(template): harden form persist edge cases and split useTemplateForm --- .../components/TemplateEdit/TemplateEdit.tsx | 7 +- .../__tests__/useTemplateForm.test.tsx | 87 ++- .../TemplateEdit/useTemplateForm.tsx | 599 ------------------ .../useTemplateForm/TemplateForm.tsx | 53 ++ .../TemplateFormPersistProvider.tsx | 188 ++++++ .../TemplateEdit/useTemplateForm/contexts.tsx | 70 ++ .../TemplateEdit/useTemplateForm/index.ts | 3 + .../templateFormDeactivation.ts | 46 ++ .../templateFormReferenceCleanup.ts | 42 ++ .../useTemplateForm/templateFormUtils.ts | 163 +++++ .../TemplateEdit/useTemplateForm/types.ts | 20 + .../useTemplateForm/useTemplateFormHook.ts | 120 ++++ 12 files changed, 797 insertions(+), 601 deletions(-) delete mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm.tsx create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/contexts.tsx create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 28a09ab9b..e6063feab 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -86,7 +86,11 @@ export function TemplateEdit({ loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(template); + const templateIdentityKey = (match.params as ITemplateEditParams).id ?? 'create'; + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm( + template, + templateIdentityKey, + ); const { tasks } = formik.values; const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; @@ -448,6 +452,7 @@ export function TemplateEdit({ return ( { }); await flushPersist(); + expect(handle!.values.owners).toEqual(owners); expect(patchTemplate).toHaveBeenCalledTimes(1); expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ owners, @@ -242,6 +243,28 @@ describe('TemplateFormPersistProvider deactivation', () => { }); }); + it('keeps consumed edits visible in Formik after a failed explicit submit', async () => { + const template = makeTemplate({ isActive: true, name: 'Original', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('name', 'Unsaved edit', false); + handle!.consumePendingChanges(); + handle!.revertConsumedChanges(); + }); + + expect(handle!.values.name).toBe('Unsaved edit'); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + name: 'Unsaved edit', + isActive: false, + }); + }); + it('does not restore the persist baseline after an explicit submit succeeds', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; @@ -262,6 +285,36 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); + it('preserves edits made after consume when an explicit submit succeeds', () => { + const template = makeTemplate({ isActive: true, name: 'Original', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} />, + ); + + act(() => { + handle!.setFieldValue('name', 'Submit edit', false); + handle!.consumePendingChanges(); + handle!.setFieldValue('name', 'Post-submit edit', false); + handle!.confirmConsumedChanges(); + }); + + rerender( + { handle = h; }} + />, + ); + + expect(handle!.values.name).toBe('Post-submit edit'); + }); + it('persists the latest value when two edits land before the deferred flush runs', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; @@ -381,16 +434,48 @@ describe('TemplateFormPersistProvider deactivation', () => { describe('useTemplateForm reinitialize', () => { function HookHarness({ currentTemplate, + templateIdentityKey, onReady, }: { currentTemplate: ITemplate; + templateIdentityKey?: string | number; onReady(result: ReturnType): void; }) { - const result = useTemplateForm(currentTemplate); + const result = useTemplateForm(currentTemplate, templateIdentityKey); onReady(result); return null; } + it('drops pending edits from a previous template when the template identity changes', () => { + const templateA = makeTemplate({ id: 1, name: 'Template A', dateUpdated: null }); + const templateB = makeTemplate({ id: 2, name: 'Template B', dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('name', 'Edited on A', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.name).toBe('Template B'); + expect(hookResult!.pendingUserEditsRef.current).toEqual({}); + expect(hookResult!.dirtyRef.current).toBe(false); + }); + it('merges pending user edits into resolved Formik initialValues', () => { const template = makeTemplate({ name: 'Original', dateUpdated: null }); let hookResult: ReturnType | null = null; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx deleted file mode 100644 index bb4a6b8c8..000000000 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm.tsx +++ /dev/null @@ -1,599 +0,0 @@ -import * as React from 'react'; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; -import { FormikProvider, useFormik, useFormikContext } from 'formik'; -import { useDispatch } from 'react-redux'; - -import { ITemplate, ITemplateTask, IKickoff } from '../../types/template'; -import { patchTemplate } from '../../redux/actions'; -import { cleanTemplateReferences } from '../../utils/template'; - -type TSetFieldValue = (field: string, value: unknown, shouldValidate?: boolean) => void; -type TSetValues = (values: ITemplate, shouldValidate?: boolean) => void; - -interface ITemplateFieldContextValue { - values: ITemplate; - setFieldValue: TSetFieldValue; - setValues: TSetValues; -} - -interface ITemplatePersistContextValue { - consumePendingChanges(): Partial; - /** Clears a revert snapshot after an explicit `patchTemplate` succeeds. */ - confirmConsumedChanges(): void; - /** Restores the persist baseline and re-queues autosave when an explicit `patchTemplate` fails. */ - revertConsumedChanges(): void; - /** Drops uncommitted edits without dispatching (e.g. after "Discard changes"). */ - abandonPendingChanges(): void; -} - -const TemplateFieldContext = createContext(null); -const TemplatePersistContext = createContext(null); - -export { TemplateFieldContext }; - -/** - * Access the root Edit Template form state + the wrapped setters. - * - * `setFieldValue` / `setValues` mark the form as user-dirty before delegating - * to Formik, so `TemplateFormPersistProvider` knows which value changes come - * from user edits (and should be saved) vs. external reinitializes from the - * server (which must not re-trigger a save — otherwise the server-stamped - * `dateUpdated`/`publicUrl` fields would cause an infinite save loop). - * - * Use this instead of `useFormikContext().setFieldValue` everywhere on the - * Edit Template page so saving stays centralized. - */ -export function useTemplateField(): ITemplateFieldContextValue { - const ctx = useContext(TemplateFieldContext); - - if (!ctx) { - throw new Error('useTemplateField must be used inside the Edit Template form provider'); - } - - return ctx; -} - -export function useTemplatePersist(): ITemplatePersistContextValue { - const ctx = useContext(TemplatePersistContext); - - if (!ctx) { - throw new Error('useTemplatePersist must be used inside the Edit Template form provider'); - } - - return ctx; -} - -function setNestedFieldValue(obj: ITemplate, path: string, value: unknown): ITemplate { - const taskPathMatch = path.match(/^tasks\.(\d+)(?:\.(.+))?$/); - - if (taskPathMatch) { - const index = Number(taskPathMatch[1]); - const taskField = taskPathMatch[2]; - const tasks = [...obj.tasks]; - - if (!taskField) { - tasks[index] = value as ITemplateTask; - } else { - tasks[index] = { - ...tasks[index], - [taskField]: value, - }; - } - - return { ...obj, tasks }; - } - - return { ...obj, [path]: value }; -} - -function applyPendingEdits( - initialValues: ITemplate, - pendingEdits: Partial, - baselineValues: ITemplate, -): ITemplate { - if (Object.keys(pendingEdits).length === 0) { - return initialValues; - } - - let mergedValues: ITemplate = { ...initialValues, ...pendingEdits }; - - if (pendingEdits.tasks && initialValues.tasks) { - mergedValues = { - ...mergedValues, - tasks: mergePreservedTasks(initialValues.tasks, pendingEdits.tasks, baselineValues.tasks), - }; - } - - return mergedValues; -} - -function overlayPendingEdits( - formikValues: ITemplate, - pendingEdits: Partial, - baselineValues: ITemplate, -): ITemplate { - if (Object.keys(pendingEdits).length === 0) { - return formikValues; - } - - return applyPendingEdits(formikValues, pendingEdits, baselineValues); -} - -function mergePreservedTasks( - incomingTasks: ITemplateTask[], - preservedTasks: ITemplateTask[], - baselineTasks: ITemplateTask[], -): ITemplateTask[] { - const incomingByUuid = new Map(incomingTasks.map((task) => [task.uuid, task])); - const baselineUuids = new Set(baselineTasks.map((task) => task.uuid)); - - const mergedFromPreserved = preservedTasks.map((preserved) => { - const incoming = incomingByUuid.get(preserved.uuid); - - if (!incoming || preserved === incoming) { - return preserved; - } - - const onlyAncestorsDiffer = (Object.keys(preserved) as (keyof ITemplateTask)[]).every( - (key) => key === 'ancestors' || preserved[key] === incoming[key], - ); - - return onlyAncestorsDiffer ? { ...preserved, ancestors: incoming.ancestors } : preserved; - }); - - const preservedUuids = new Set(preservedTasks.map((task) => task.uuid)); - const serverAddedTasks = incomingTasks.filter( - (task) => !baselineUuids.has(task.uuid) && !preservedUuids.has(task.uuid), - ); - - if (serverAddedTasks.length === 0) { - return mergedFromPreserved; - } - - const sortedServerAdded = [...serverAddedTasks].sort((a, b) => a.number - b.number); - const preservedOrderNumber = (task: ITemplateTask) => - incomingByUuid.get(task.uuid)?.number ?? task.number; - - const insertState = { serverIndex: 0 }; - - const interleaved = mergedFromPreserved.reduce((result, preserved) => { - while ( - insertState.serverIndex < sortedServerAdded.length - && sortedServerAdded[insertState.serverIndex].number < preservedOrderNumber(preserved) - ) { - result.push(sortedServerAdded[insertState.serverIndex]); - insertState.serverIndex += 1; - } - result.push(preserved); - return result; - }, []); - - return [...interleaved, ...sortedServerAdded.slice(insertState.serverIndex)]; -} - -function getKickoffFieldApiNames(kickoff: IKickoff): string[] { - return (kickoff.fields || []) - .map((field) => field.apiName) - .filter(Boolean) - .sort(); -} - -function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { - const previousNames = getKickoffFieldApiNames(previous); - const nextNames = getKickoffFieldApiNames(next); - - return previousNames.length !== nextNames.length - || previousNames.some((name, index) => name !== nextNames[index]); -} - -function shouldRunReferenceCleanup(field: string, previous: ITemplate, next: ITemplate): boolean { - if (field === 'tasks' || field.startsWith('tasks.')) { - return true; - } - - if (field === 'kickoff') { - return didKickoffFieldsChange(previous.kickoff, next.kickoff); - } - - return false; -} - -function applyReferenceCleanup(template: ITemplate): ITemplate { - const cleaned = cleanTemplateReferences(template); - const wfNameTemplate = template.wfNameTemplate == null && cleaned.wfNameTemplate === '' - ? template.wfNameTemplate - : cleaned.wfNameTemplate; - - return { - ...template, - tasks: cleaned.tasks, - wfNameTemplate, - }; -} - -function getChangedFields(previous: ITemplate, next: ITemplate): Partial { - const changedFields: Partial = {}; - - (Object.keys(next) as (keyof ITemplate)[]).forEach((key) => { - if (previous[key] !== next[key]) { - (changedFields[key] as ITemplate[keyof ITemplate]) = next[key]; - } - }); - - return changedFields; -} - -/** - * Root Formik context for the whole Edit Template page. - * - * The entire `ITemplate` (name, description, kickoff, tasks, owners, share - * settings, toggles, ...) lives in a single Formik state. Every field binds to - * it via `useTemplateField`; nothing dispatches Redux field updates from - * individual inputs anymore. Saving happens from one place only — - * `TemplateFormPersistProvider` — which dispatches a single `patchTemplate` - * action for user edits (the saga then debounces + saves). Explicit "submit" - * flows (e.g. activating the template) still go through `patchTemplate` with - * callbacks, so both onChange and submit share one save path. - */ -export function useTemplateForm(initialValues: ITemplate) { - const dirtyRef = useRef(false); - const pendingUserEditsRef = useRef>({}); - const lastSyncedInitialValuesRef = useRef(initialValues); - const persistBaselineSyncRef = useRef<((reduxTemplate: ITemplate) => void) | null>(null); - - const formik = useFormik({ - initialValues, - enableReinitialize: false, - onSubmit: () => undefined, - }); - - // Redux `template` updates must be merged into Formik without discarding edits - // that still live only in the form (e.g. typed while a save is in flight). - // Wrapped setters accumulate those edits in `pendingUserEditsRef` because a - // Redux snapshot can land in the same render and overwrite Formik state. - if (lastSyncedInitialValuesRef.current !== initialValues) { - const rawPending = dirtyRef.current ? pendingUserEditsRef.current : {}; - - if (Object.keys(rawPending).length > 0) { - const mergedValues = applyPendingEdits(initialValues, rawPending, lastSyncedInitialValuesRef.current); - - pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); - dirtyRef.current = true; - formik.setValues(mergedValues, false); - persistBaselineSyncRef.current?.(initialValues); - } else { - pendingUserEditsRef.current = {}; - dirtyRef.current = false; - formik.resetForm({ values: initialValues }); - } - - lastSyncedInitialValuesRef.current = initialValues; - } - - const setFieldValue = useCallback( - (field, value, shouldValidate) => { - dirtyRef.current = true; - const currentValues = overlayPendingEdits( - formik.values, - pendingUserEditsRef.current, - lastSyncedInitialValuesRef.current, - ); - let nextValues = setNestedFieldValue(currentValues, field, value); - const runCleanup = shouldRunReferenceCleanup(field, currentValues, nextValues); - - if (runCleanup) { - nextValues = applyReferenceCleanup(nextValues); - } - - nextValues = applyImmediateDeactivation(currentValues, nextValues); - pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); - - if (runCleanup || nextValues.isActive !== currentValues.isActive) { - formik.setValues(nextValues, shouldValidate); - } else { - formik.setFieldValue(field, value, shouldValidate); - } - }, - [formik], - ); - - const setValues = useCallback( - (values, shouldValidate) => { - dirtyRef.current = true; - const currentValues = overlayPendingEdits( - formik.values, - pendingUserEditsRef.current, - lastSyncedInitialValuesRef.current, - ); - const nextValues = applyImmediateDeactivation(currentValues, values); - pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); - formik.setValues(nextValues, shouldValidate); - }, - [formik, formik.values], - ); - - return { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef }; -} - -interface ITemplateFormProps { - formik: ReturnType>; - setFieldValue: TSetFieldValue; - setValues: TSetValues; - dirtyRef: React.MutableRefObject; - pendingUserEditsRef: React.MutableRefObject>; - persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; - children: React.ReactNode; -} - -/** - * Bundles the Formik provider, the wrapped-setter context, and the single - * persist provider so `TemplateEdit` only has to render ``. - */ -export function TemplateForm({ - formik, - setFieldValue, - setValues, - dirtyRef, - pendingUserEditsRef, - persistBaselineSyncRef, - children, -}: ITemplateFormProps) { - const { values } = formik; - const fieldContextValue = useMemo( - () => ({ values, setFieldValue, setValues }), - [values, setFieldValue, setValues], - ); - - return ( - - - - {children} - - - - ); -} - -// Keep in sync with `patchTemplateSaga` in redux/template/saga.ts. The saga -// flips `isActive` to false server-side whenever a non-activation field changes -// and only reflects it back to Formik via a Redux `setTemplate` reinitialize. -// `applyImmediateDeactivation` mirrors that decision in the wrapped setters so -// Formik (and controls like `RouteLeavingGuard` that read from it) deactivate -// on the same tick as the edit instead of waiting for the deferred persist flush. -const NON_DEACTIVATIVE_FIELDS: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; - -function shouldDeactivateTemplate( - changedFields: Partial, - previousTemplate: ITemplate, -): boolean { - if (changedFields.isActive === true) { - return false; - } - - let shouldDeactivate = Object.keys(changedFields).some( - (key) => !NON_DEACTIVATIVE_FIELDS.includes(key as keyof ITemplate), - ); - - if (Object.keys(changedFields).length === 1 && Object.prototype.hasOwnProperty.call(changedFields, 'kickoff')) { - shouldDeactivate = - changedFields.kickoff?.description === previousTemplate.kickoff.description - ? shouldDeactivate - : false; - } - - return shouldDeactivate; -} - -function applyImmediateDeactivation(previous: ITemplate, next: ITemplate): ITemplate { - if (!previous.isActive) { - return next; - } - - const changedFields = getChangedFields(previous, next); - - if (shouldDeactivateTemplate(changedFields, previous)) { - return { ...next, isActive: false }; - } - - return next; -} - -interface ITemplateFormPersistProviderProps { - dirtyRef: React.MutableRefObject; - pendingUserEditsRef: React.MutableRefObject>; - persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; - children: React.ReactNode; -} - -/** - * Single save point for every user edit on the Edit Template page. - * - * On each Formik values change we compute a shallow top-level diff and — only - * when the change was initiated by a user via the wrapped `setFieldValue`/ - * `setValues` (i.e. `dirtyRef` is true) — dispatch `patchTemplate`. The - * `patchTemplate` saga owns the actual debounce + API call. External - * reinitializes (template load, save response with server-stamped fields, - * owners normalization, ...) update `previousValuesRef` but never dispatch, so - * they cannot cause a save loop. - * - * When a user edit touches a non-activation field, the wrapped `setFieldValue`/ - * `setValues` flip `isActive` to false in Formik synchronously (see - * `applyImmediateDeactivation`) so controls like `RouteLeavingGuard` react - * immediately. The saga makes the same call server-side but only reinitializes - * Formik after the save round-trip. - * - * On unmount the values effect cleanup calls `flushPersist` so edits made just - * before leaving the page are not lost. Call `abandonPendingChanges` before - * navigating away after "Discard changes" so those edits are not re-dispatched. - */ -export function TemplateFormPersistProvider({ - dirtyRef, - pendingUserEditsRef, - persistBaselineSyncRef, - children, -}: ITemplateFormPersistProviderProps) { - const { values } = useFormikContext(); - const dispatch = useDispatch(); - const previousValuesRef = useRef(values); - const valuesRef = useRef(values); - const dispatchRef = useRef(dispatch); - const dirtyRefRef = useRef(dirtyRef); - const pendingUserEditsRefRef = useRef(pendingUserEditsRef); - const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); - - valuesRef.current = values; - dispatchRef.current = dispatch; - dirtyRefRef.current = dirtyRef; - pendingUserEditsRefRef.current = pendingUserEditsRef; - persistBaselineSyncRefRef.current = persistBaselineSyncRef; - - useEffect(() => { - persistBaselineSyncRefRef.current.current = (reduxTemplate) => { - // Baseline must mirror the latest Redux snapshot for every field so - // server-stamped or normalized values (dateUpdated, owners, ...) are not - // diffed as user edits on the next flush. - previousValuesRef.current = { ...reduxTemplate }; - }; - - return () => { - persistBaselineSyncRefRef.current.current = null; - }; - }, []); - - const consumedPendingRef = useRef<{ previousBaseline: ITemplate; isUserEdit: boolean } | null>(null); - - const takePendingChanges = useCallback((trackForRevert = false) => { - if (previousValuesRef.current === valuesRef.current) { - return {}; - } - - const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); - const isUserEdit = dirtyRefRef.current.current; - - if (trackForRevert && isUserEdit && Object.keys(changedFields).length > 0) { - consumedPendingRef.current = { - previousBaseline: previousValuesRef.current, - isUserEdit, - }; - } - - previousValuesRef.current = valuesRef.current; - dirtyRefRef.current.current = false; - - if (isUserEdit && Object.keys(changedFields).length > 0 && !trackForRevert) { - pendingUserEditsRefRef.current.current = {}; - } - - return isUserEdit ? changedFields : {}; - }, []); - - const consumePendingChanges = useCallback(() => takePendingChanges(true), [takePendingChanges]); - - const confirmConsumedChanges = useCallback(() => { - consumedPendingRef.current = null; - pendingUserEditsRefRef.current.current = {}; - }, []); - - const flushPersist = useCallback(() => { - const changedFields = takePendingChanges(); - - if (Object.keys(changedFields).length > 0) { - dispatchRef.current(patchTemplate({ changedFields })); - } - }, [takePendingChanges]); - - const revertConsumedChanges = useCallback(() => { - const consumed = consumedPendingRef.current; - - if (!consumed) { - return; - } - - previousValuesRef.current = consumed.previousBaseline; - dirtyRefRef.current.current = consumed.isUserEdit; - pendingUserEditsRefRef.current.current = getChangedFields( - consumed.previousBaseline, - valuesRef.current, - ); - consumedPendingRef.current = null; - - // Restoring the baseline does not change Formik `values`, so the values - // effect won't re-run and re-dispatch. This runs from a saga `onFailed` - // callback (outside React render), so it's safe to persist the restored - // edits right away instead of stranding them. - flushPersist(); - }, [flushPersist]); - - const abandonPendingChanges = useCallback(() => { - previousValuesRef.current = valuesRef.current; - dirtyRefRef.current.current = false; - pendingUserEditsRefRef.current.current = {}; - consumedPendingRef.current = null; - }, []); - - useEffect(() => { - if (previousValuesRef.current === values) { - return undefined; - } - - const timeoutId = window.setTimeout(() => { - flushPersist(); - }, 0); - - return () => { - window.clearTimeout(timeoutId); - flushPersist(); - }; - }, [values, flushPersist]); - - const persistContextValue = useMemo( - () => ({ - consumePendingChanges, - confirmConsumedChanges, - revertConsumedChanges, - abandonPendingChanges, - }), - [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges], - ); - - return ( - - {children as React.ReactElement} - - ); -} - -const TaskFormScopeContext = createContext(null); - -interface ITaskFormScopeProviderProps { - taskUuid: string; - children: React.ReactNode; -} - -/** - * Scopes the task form sections to a single task inside the root `ITemplate` - * Formik context. Descendants call `useTaskForm()` (no args) and get the - * matching task + updaters bound to `tasks[index].`. - */ -export function TaskFormScopeProvider({ taskUuid, children }: ITaskFormScopeProviderProps) { - return {children}; -} - -/** - * Returns the uuid of the task currently scoped via `TaskFormScopeProvider`. - * Throws if used outside the provider. - */ -export function useTaskFormScope(): string { - const taskUuid = useContext(TaskFormScopeContext); - - if (taskUuid === null) { - throw new Error('useTaskForm must be used inside '); - } - - return taskUuid; -} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx new file mode 100644 index 000000000..9dfb71e1f --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import { FormikProvider, useFormik } from 'formik'; + +import { ITemplate } from '../../../types/template'; +import { TemplateFieldContext } from './contexts'; +import { TemplateFormPersistProvider } from './TemplateFormPersistProvider'; +import { ITemplateFieldContextValue, TSetFieldValue, TSetValues } from './types'; + +interface ITemplateFormProps { + formik: ReturnType>; + setFieldValue: TSetFieldValue; + setValues: TSetValues; + dirtyRef: React.MutableRefObject; + pendingUserEditsRef: React.MutableRefObject>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; + children: React.ReactNode; +} + +/** + * Bundles the Formik provider, the wrapped-setter context, and the single + * persist provider so `TemplateEdit` only has to render ``. + */ +export function TemplateForm({ + formik, + setFieldValue, + setValues, + dirtyRef, + pendingUserEditsRef, + persistBaselineSyncRef, + children, +}: ITemplateFormProps) { + const { values } = formik; + const fieldContextValue = useMemo( + () => ({ values, setFieldValue, setValues }), + [values, setFieldValue, setValues], + ); + + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx new file mode 100644 index 000000000..8e2117142 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useFormikContext } from 'formik'; +import { useDispatch } from 'react-redux'; + +import { ITemplate } from '../../../types/template'; +import { patchTemplate } from '../../../redux/actions'; +import { TemplatePersistContext } from './contexts'; +import { getChangedFields, getUnconsumedPendingEdits } from './templateFormUtils'; +import { ITemplatePersistContextValue, TSetValues } from './types'; + +interface ITemplateFormPersistProviderProps { + dirtyRef: React.MutableRefObject; + pendingUserEditsRef: React.MutableRefObject>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; + setValues: TSetValues; + children: React.ReactNode; +} + +/** + * Single save point for every user edit on the Edit Template page. + * + * On each Formik values change we compute a shallow top-level diff and — only + * when the change was initiated by a user via the wrapped `setFieldValue`/ + * `setValues` (i.e. `dirtyRef` is true) — dispatch `patchTemplate`. The + * `patchTemplate` saga owns the actual debounce + API call. External + * reinitializes (template load, save response with server-stamped fields, + * owners normalization, ...) update `previousValuesRef` but never dispatch, so + * they cannot cause a save loop. + * + * When a user edit touches a non-activation field, the wrapped `setFieldValue`/ + * `setValues` flip `isActive` to false in Formik synchronously (see + * `applyImmediateDeactivation`) so controls like `RouteLeavingGuard` react + * immediately. The saga makes the same call server-side but only reinitializes + * Formik after the save round-trip. + * + * On unmount the values effect cleanup calls `flushPersist` so edits made just + * before leaving the page are not lost. Call `abandonPendingChanges` before + * navigating away after "Discard changes" so those edits are not re-dispatched. + */ +export function TemplateFormPersistProvider({ + dirtyRef, + pendingUserEditsRef, + persistBaselineSyncRef, + setValues, + children, +}: ITemplateFormPersistProviderProps) { + const { values } = useFormikContext(); + const dispatch = useDispatch(); + const previousValuesRef = useRef(values); + const valuesRef = useRef(values); + const dispatchRef = useRef(dispatch); + const dirtyRefRef = useRef(dirtyRef); + const pendingUserEditsRefRef = useRef(pendingUserEditsRef); + const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); + const setValuesRef = useRef(setValues); + + valuesRef.current = values; + dispatchRef.current = dispatch; + dirtyRefRef.current = dirtyRef; + pendingUserEditsRefRef.current = pendingUserEditsRef; + persistBaselineSyncRefRef.current = persistBaselineSyncRef; + setValuesRef.current = setValues; + + useEffect(() => { + persistBaselineSyncRefRef.current.current = (reduxTemplate) => { + // Baseline must mirror the latest Redux snapshot for every field so + // server-stamped or normalized values (dateUpdated, owners, ...) are not + // diffed as user edits on the next flush. + previousValuesRef.current = { ...reduxTemplate }; + }; + + return () => { + persistBaselineSyncRefRef.current.current = null; + }; + }, []); + + const consumedPendingRef = useRef<{ + previousBaseline: ITemplate; + isUserEdit: boolean; + pendingUserEdits: Partial; + } | null>(null); + + const takePendingChanges = useCallback((trackForRevert = false) => { + if (previousValuesRef.current === valuesRef.current) { + return {}; + } + + const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); + const isUserEdit = dirtyRefRef.current.current; + + if (trackForRevert && isUserEdit && Object.keys(changedFields).length > 0) { + consumedPendingRef.current = { + previousBaseline: previousValuesRef.current, + isUserEdit, + pendingUserEdits: { ...pendingUserEditsRefRef.current.current }, + }; + } + + previousValuesRef.current = valuesRef.current; + dirtyRefRef.current.current = false; + + if (isUserEdit && Object.keys(changedFields).length > 0 && !trackForRevert) { + pendingUserEditsRefRef.current.current = {}; + } + + return isUserEdit ? changedFields : {}; + }, []); + + const consumePendingChanges = useCallback(() => takePendingChanges(true), [takePendingChanges]); + + const confirmConsumedChanges = useCallback(() => { + const consumed = consumedPendingRef.current; + + if (consumed) { + pendingUserEditsRefRef.current.current = getUnconsumedPendingEdits( + consumed.pendingUserEdits, + pendingUserEditsRefRef.current.current, + ); + } + + consumedPendingRef.current = null; + }, []); + + const flushPersist = useCallback(() => { + const changedFields = takePendingChanges(); + + if (Object.keys(changedFields).length > 0) { + dispatchRef.current(patchTemplate({ changedFields })); + } + }, [takePendingChanges]); + + const revertConsumedChanges = useCallback(() => { + const consumed = consumedPendingRef.current; + + if (!consumed) { + return; + } + + previousValuesRef.current = consumed.previousBaseline; + consumedPendingRef.current = null; + + // Restoring the baseline does not change Formik `values`, so the values + // effect won't re-run. Re-apply the current values through the wrapped + // setter so dirty state, pending edits, and the autosave effect stay aligned. + if (consumed.isUserEdit && previousValuesRef.current !== valuesRef.current) { + setValuesRef.current({ ...valuesRef.current }, false); + } + }, []); + + const abandonPendingChanges = useCallback(() => { + previousValuesRef.current = valuesRef.current; + dirtyRefRef.current.current = false; + pendingUserEditsRefRef.current.current = {}; + consumedPendingRef.current = null; + }, []); + + useEffect(() => { + if (previousValuesRef.current === values) { + return undefined; + } + + const timeoutId = window.setTimeout(() => { + flushPersist(); + }, 0); + + return () => { + window.clearTimeout(timeoutId); + flushPersist(); + }; + }, [values, flushPersist]); + + const persistContextValue = useMemo( + () => ({ + consumePendingChanges, + confirmConsumedChanges, + revertConsumedChanges, + abandonPendingChanges, + }), + [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges], + ); + + return ( + + {children as React.ReactElement} + + ); +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/contexts.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/contexts.tsx new file mode 100644 index 000000000..d70525fc6 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/contexts.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { createContext, useContext } from 'react'; + +import { ITemplateFieldContextValue, ITemplatePersistContextValue } from './types'; + +export const TemplateFieldContext = createContext(null); +const TemplatePersistContext = createContext(null); +const TaskFormScopeContext = createContext(null); + +/** + * Access the root Edit Template form state + the wrapped setters. + * + * `setFieldValue` / `setValues` mark the form as user-dirty before delegating + * to Formik, so `TemplateFormPersistProvider` knows which value changes come + * from user edits (and should be saved) vs. external reinitializes from the + * server (which must not re-trigger a save — otherwise the server-stamped + * `dateUpdated`/`publicUrl` fields would cause an infinite save loop). + * + * Use this instead of `useFormikContext().setFieldValue` everywhere on the + * Edit Template page so saving stays centralized. + */ +export function useTemplateField(): ITemplateFieldContextValue { + const ctx = useContext(TemplateFieldContext); + + if (!ctx) { + throw new Error('useTemplateField must be used inside the Edit Template form provider'); + } + + return ctx; +} + +export function useTemplatePersist(): ITemplatePersistContextValue { + const ctx = useContext(TemplatePersistContext); + + if (!ctx) { + throw new Error('useTemplatePersist must be used inside the Edit Template form provider'); + } + + return ctx; +} + +export { TemplatePersistContext }; + +interface ITaskFormScopeProviderProps { + taskUuid: string; + children: React.ReactNode; +} + +/** + * Scopes the task form sections to a single task inside the root `ITemplate` + * Formik context. Descendants call `useTaskForm()` (no args) and get the + * matching task + updaters bound to `tasks[index].`. + */ +export function TaskFormScopeProvider({ taskUuid, children }: ITaskFormScopeProviderProps) { + return {children}; +} + +/** + * Returns the uuid of the task currently scoped via `TaskFormScopeProvider`. + * Throws if used outside the provider. + */ +export function useTaskFormScope(): string { + const taskUuid = useContext(TaskFormScopeContext); + + if (taskUuid === null) { + throw new Error('useTaskForm must be used inside '); + } + + return taskUuid; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts new file mode 100644 index 000000000..26269dfa9 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts @@ -0,0 +1,3 @@ +export { TemplateFieldContext, TaskFormScopeProvider, useTaskFormScope, useTemplateField, useTemplatePersist } from './contexts'; +export { TemplateForm } from './TemplateForm'; +export { useTemplateForm } from './useTemplateFormHook'; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts new file mode 100644 index 000000000..165f75b33 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts @@ -0,0 +1,46 @@ +import { ITemplate } from '../../../types/template'; +import { getChangedFields } from './templateFormUtils'; + +// Keep in sync with `patchTemplateSaga` in redux/template/saga.ts. The saga +// flips `isActive` to false server-side whenever a non-activation field changes +// and only reflects it back to Formik via a Redux `setTemplate` reinitialize. +// `applyImmediateDeactivation` mirrors that decision in the wrapped setters so +// Formik (and controls like `RouteLeavingGuard` that read from it) deactivate +// on the same tick as the edit instead of waiting for the deferred persist flush. +const NON_DEACTIVATIVE_FIELDS: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; + +function shouldDeactivateTemplate( + changedFields: Partial, + previousTemplate: ITemplate, +): boolean { + if (changedFields.isActive === true) { + return false; + } + + let shouldDeactivate = Object.keys(changedFields).some( + (key) => !NON_DEACTIVATIVE_FIELDS.includes(key as keyof ITemplate), + ); + + if (Object.keys(changedFields).length === 1 && Object.prototype.hasOwnProperty.call(changedFields, 'kickoff')) { + shouldDeactivate = + changedFields.kickoff?.description === previousTemplate.kickoff.description + ? shouldDeactivate + : false; + } + + return shouldDeactivate; +} + +export function applyImmediateDeactivation(previous: ITemplate, next: ITemplate): ITemplate { + if (!previous.isActive) { + return next; + } + + const changedFields = getChangedFields(previous, next); + + if (shouldDeactivateTemplate(changedFields, previous)) { + return { ...next, isActive: false }; + } + + return next; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts new file mode 100644 index 000000000..202b1c416 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts @@ -0,0 +1,42 @@ +import { IKickoff, ITemplate } from '../../../types/template'; +import { cleanTemplateReferences } from '../../../utils/template'; + +function getKickoffFieldApiNames(kickoff: IKickoff): string[] { + return (kickoff.fields || []) + .map((field) => field.apiName) + .filter(Boolean) + .sort(); +} + +function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { + const previousNames = getKickoffFieldApiNames(previous); + const nextNames = getKickoffFieldApiNames(next); + + return previousNames.length !== nextNames.length + || previousNames.some((name, index) => name !== nextNames[index]); +} + +export function shouldRunReferenceCleanup(field: string, previous: ITemplate, next: ITemplate): boolean { + if (field === 'tasks' || field.startsWith('tasks.')) { + return true; + } + + if (field === 'kickoff') { + return didKickoffFieldsChange(previous.kickoff, next.kickoff); + } + + return false; +} + +export function applyReferenceCleanup(template: ITemplate): ITemplate { + const cleaned = cleanTemplateReferences(template); + const wfNameTemplate = template.wfNameTemplate == null && cleaned.wfNameTemplate === '' + ? template.wfNameTemplate + : cleaned.wfNameTemplate; + + return { + ...template, + tasks: cleaned.tasks, + wfNameTemplate, + }; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts new file mode 100644 index 000000000..535dc8f91 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts @@ -0,0 +1,163 @@ +import { ITemplate, ITemplateTask } from '../../../types/template'; + +export function setNestedFieldValue(obj: ITemplate, path: string, value: unknown): ITemplate { + const taskPathMatch = path.match(/^tasks\.(\d+)(?:\.(.+))?$/); + + if (taskPathMatch) { + const index = Number(taskPathMatch[1]); + const taskField = taskPathMatch[2]; + const tasks = [...obj.tasks]; + + if (!taskField) { + tasks[index] = value as ITemplateTask; + } else { + tasks[index] = { + ...tasks[index], + [taskField]: value, + }; + } + + return { ...obj, tasks }; + } + + return { ...obj, [path]: value }; +} + +export function mergePreservedTasks( + incomingTasks: ITemplateTask[], + preservedTasks: ITemplateTask[], + baselineTasks: ITemplateTask[], +): ITemplateTask[] { + const incomingByUuid = new Map(incomingTasks.map((task) => [task.uuid, task])); + const baselineUuids = new Set(baselineTasks.map((task) => task.uuid)); + + const mergedFromPreserved = preservedTasks.map((preserved) => { + const incoming = incomingByUuid.get(preserved.uuid); + + if (!incoming || preserved === incoming) { + return preserved; + } + + const onlyAncestorsDiffer = (Object.keys(preserved) as (keyof ITemplateTask)[]).every( + (key) => key === 'ancestors' || preserved[key] === incoming[key], + ); + + return onlyAncestorsDiffer ? { ...preserved, ancestors: incoming.ancestors } : preserved; + }); + + const preservedUuids = new Set(preservedTasks.map((task) => task.uuid)); + const serverAddedTasks = incomingTasks.filter( + (task) => !baselineUuids.has(task.uuid) && !preservedUuids.has(task.uuid), + ); + + if (serverAddedTasks.length === 0) { + return mergedFromPreserved; + } + + const sortedServerAdded = [...serverAddedTasks].sort((a, b) => a.number - b.number); + const preservedOrderNumber = (task: ITemplateTask) => + incomingByUuid.get(task.uuid)?.number ?? task.number; + + const insertState = { serverIndex: 0 }; + + const interleaved = mergedFromPreserved.reduce((result, preserved) => { + while ( + insertState.serverIndex < sortedServerAdded.length + && sortedServerAdded[insertState.serverIndex].number < preservedOrderNumber(preserved) + ) { + result.push(sortedServerAdded[insertState.serverIndex]); + insertState.serverIndex += 1; + } + result.push(preserved); + return result; + }, []); + + return [...interleaved, ...sortedServerAdded.slice(insertState.serverIndex)]; +} + +export function applyPendingEdits( + initialValues: ITemplate, + pendingEdits: Partial, + baselineValues: ITemplate, +): ITemplate { + if (Object.keys(pendingEdits).length === 0) { + return initialValues; + } + + let mergedValues: ITemplate = { ...initialValues, ...pendingEdits }; + + if (pendingEdits.tasks && initialValues.tasks) { + mergedValues = { + ...mergedValues, + tasks: mergePreservedTasks(initialValues.tasks, pendingEdits.tasks, baselineValues.tasks), + }; + } + + return mergedValues; +} + +export function overlayPendingEdits( + formikValues: ITemplate, + pendingEdits: Partial, + baselineValues: ITemplate, +): ITemplate { + if (Object.keys(pendingEdits).length === 0) { + return formikValues; + } + + return applyPendingEdits(formikValues, pendingEdits, baselineValues); +} + +export function getChangedFields(previous: ITemplate, next: ITemplate): Partial { + const changedFields: Partial = {}; + + (Object.keys(next) as (keyof ITemplate)[]).forEach((key) => { + if (previous[key] !== next[key]) { + (changedFields[key] as ITemplate[keyof ITemplate]) = next[key]; + } + }); + + return changedFields; +} + +export function getUnconsumedPendingEdits( + consumed: Partial, + current: Partial, +): Partial { + const remainder: Partial = {}; + + (Object.keys(current) as (keyof ITemplate)[]).forEach((key) => { + if (current[key] !== consumed[key]) { + (remainder[key] as ITemplate[keyof ITemplate]) = current[key]; + } + }); + + return remainder; +} + +export function resolveTemplateIdentity( + initialValues: ITemplate, + templateIdentityKey?: string | number, +): string | number | undefined { + if (templateIdentityKey !== undefined) { + return templateIdentityKey; + } + + return initialValues.id; +} + +export function hasTemplateIdentityChanged( + previousIdentity: string | number | undefined, + nextIdentity: string | number | undefined, +): boolean { + if (previousIdentity === nextIdentity) { + return false; + } + + // First id assignment after create — same template session. + if (previousIdentity === undefined && typeof nextIdentity === 'number') { + return false; + } + + return previousIdentity !== undefined || nextIdentity !== undefined; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts new file mode 100644 index 000000000..2bd258237 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts @@ -0,0 +1,20 @@ +import { ITemplate } from '../../../types/template'; + +export type TSetFieldValue = (field: string, value: unknown, shouldValidate?: boolean) => void; +export type TSetValues = (values: ITemplate, shouldValidate?: boolean) => void; + +export interface ITemplateFieldContextValue { + values: ITemplate; + setFieldValue: TSetFieldValue; + setValues: TSetValues; +} + +export interface ITemplatePersistContextValue { + consumePendingChanges(): Partial; + /** Clears a revert snapshot after an explicit `patchTemplate` succeeds. */ + confirmConsumedChanges(): void; + /** Restores the persist baseline and re-queues autosave when an explicit `patchTemplate` fails. */ + revertConsumedChanges(): void; + /** Drops uncommitted edits without dispatching (e.g. after "Discard changes"). */ + abandonPendingChanges(): void; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts new file mode 100644 index 000000000..9399c6cf0 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts @@ -0,0 +1,120 @@ +import { useCallback, useRef } from 'react'; +import { useFormik } from 'formik'; + +import { ITemplate } from '../../../types/template'; +import { applyImmediateDeactivation } from './templateFormDeactivation'; +import { applyReferenceCleanup, shouldRunReferenceCleanup } from './templateFormReferenceCleanup'; +import { + applyPendingEdits, + getChangedFields, + hasTemplateIdentityChanged, + overlayPendingEdits, + resolveTemplateIdentity, + setNestedFieldValue, +} from './templateFormUtils'; +import { TSetFieldValue, TSetValues } from './types'; + +/** + * Root Formik context for the whole Edit Template page. + * + * The entire `ITemplate` (name, description, kickoff, tasks, owners, share + * settings, toggles, ...) lives in a single Formik state. Every field binds to + * it via `useTemplateField`; nothing dispatches Redux field updates from + * individual inputs anymore. Saving happens from one place only — + * `TemplateFormPersistProvider` — which dispatches a single `patchTemplate` + * action for user edits (the saga then debounces + saves). Explicit "submit" + * flows (e.g. activating the template) still go through `patchTemplate` with + * callbacks, so both onChange and submit share one save path. + */ +export function useTemplateForm( + initialValues: ITemplate, + templateIdentityKey?: string | number, +) { + const dirtyRef = useRef(false); + const pendingUserEditsRef = useRef>({}); + const lastSyncedInitialValuesRef = useRef(initialValues); + const persistBaselineSyncRef = useRef<((reduxTemplate: ITemplate) => void) | null>(null); + const resolvedIdentity = resolveTemplateIdentity(initialValues, templateIdentityKey); + const lastTemplateIdentityRef = useRef(resolvedIdentity); + + const formik = useFormik({ + initialValues, + enableReinitialize: false, + onSubmit: () => undefined, + }); + + if (hasTemplateIdentityChanged(lastTemplateIdentityRef.current, resolvedIdentity)) { + dirtyRef.current = false; + pendingUserEditsRef.current = {}; + lastSyncedInitialValuesRef.current = initialValues; + lastTemplateIdentityRef.current = resolvedIdentity; + formik.resetForm({ values: initialValues }); + } + + // Redux `template` updates must be merged into Formik without discarding edits + // that still live only in the form (e.g. typed while a save is in flight). + // Wrapped setters accumulate those edits in `pendingUserEditsRef` because a + // Redux snapshot can land in the same render and overwrite Formik state. + if (lastSyncedInitialValuesRef.current !== initialValues) { + const rawPending = dirtyRef.current ? pendingUserEditsRef.current : {}; + + if (Object.keys(rawPending).length > 0) { + const mergedValues = applyPendingEdits(initialValues, rawPending, lastSyncedInitialValuesRef.current); + + pendingUserEditsRef.current = getChangedFields(initialValues, mergedValues); + dirtyRef.current = true; + formik.setValues(mergedValues, false); + persistBaselineSyncRef.current?.(initialValues); + } else { + pendingUserEditsRef.current = {}; + dirtyRef.current = false; + formik.resetForm({ values: initialValues }); + } + + lastSyncedInitialValuesRef.current = initialValues; + } + + const setFieldValue = useCallback( + (field, value, shouldValidate) => { + dirtyRef.current = true; + const currentValues = overlayPendingEdits( + formik.values, + pendingUserEditsRef.current, + lastSyncedInitialValuesRef.current, + ); + let nextValues = setNestedFieldValue(currentValues, field, value); + const runCleanup = shouldRunReferenceCleanup(field, currentValues, nextValues); + + if (runCleanup) { + nextValues = applyReferenceCleanup(nextValues); + } + + nextValues = applyImmediateDeactivation(currentValues, nextValues); + pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); + + if (runCleanup || nextValues.isActive !== currentValues.isActive) { + formik.setValues(nextValues, shouldValidate); + } else { + formik.setFieldValue(field, value, shouldValidate); + } + }, + [formik], + ); + + const setValues = useCallback( + (values, shouldValidate) => { + dirtyRef.current = true; + const currentValues = overlayPendingEdits( + formik.values, + pendingUserEditsRef.current, + lastSyncedInitialValuesRef.current, + ); + const nextValues = applyImmediateDeactivation(currentValues, values); + pendingUserEditsRef.current = getChangedFields(lastSyncedInitialValuesRef.current, nextValues); + formik.setValues(nextValues, shouldValidate); + }, + [formik, formik.values], + ); + + return { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef }; +} From 53cba7db3088a6fb357c0e34c0a80fb15af22217 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 15:25:56 +0300 Subject: [PATCH 25/39] 59876 refactor(template): split TemplateEdit and sync variables from Formik --- .../components/TemplateEdit/TemplateEdit.tsx | 480 ++---------------- .../TemplateEdit/TemplateEditLayout.tsx | 65 +++ .../TemplateEdit/templateEditPage.types.ts | 38 ++ .../TemplateEdit/useTemplateEditInit.ts | 141 +++++ .../TemplateEdit/useTemplateEditTasks.ts | 194 +++++++ .../utils/createTemplateEditTask.ts | 93 ++++ 6 files changed, 572 insertions(+), 439 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/TemplateEditLayout.tsx create mode 100644 frontend/src/public/components/TemplateEdit/templateEditPage.types.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts create mode 100644 frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index e6063feab..9fa78640b 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -1,73 +1,18 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useSelector } from 'react-redux'; import { useIntl } from 'react-intl'; -import { RouteComponentProps } from 'react-router-dom'; -import { IInfoWarningProps } from './InfoWarningsModal'; -import { getClonedTask } from './utils/getClonedTask'; -import { AutoSaveStatusContainer } from './AutoSaveStatus'; -import { TemplateEntity } from './TemplateEntity'; -import { AddEntityButton, EEntityTitle } from './AddEntityButton'; -import { START_DURATION, DEFAULT_TEMPLATE_NAME } from './constants'; -import { getVariables } from './TaskForm/utils/getTaskVariables'; -import { TemplateIntegrations } from './Integrations'; -import { ERoutes } from '../../constants/routes'; -import { TUserListItem } from '../../types/user'; -import { getEmptyKickoff, getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template'; -import { checkSomeRouteIsActive, isCreateTemplate } from '../../utils/history'; -import { KickoffReduxContainer } from './KickoffRedux'; -import { moveTask } from '../../utils/workflows'; -import { NotificationManager } from '../UI/Notifications'; -import { isArrayWithItems } from '../../utils/helpers'; -import { createOwnerApiName, createPerformerApiName, createTaskApiName, createUUID } from '../../utils/createId'; -import { EMoveDirections } from '../../types/workflow'; -import { ETaskPerformerType, ETemplateOwnerRole, ETemplateOwnerType, ITemplate, ITemplateTask } from '../../types/template'; -import { TLoadTemplateVariablesSuccessPayload } from '../../redux/actions'; -import { ETemplateStatus, IAuthUser } from '../../types/redux'; -import { getKickoffConditions } from './TaskForm/Conditions/utils/getKickoffConditions'; -import { getStartTaskConditions } from './TaskForm/Conditions/utils/getStartTaskConditions'; -import { createEmptyTaskDueDate } from '../../utils/dueDate/createEmptyTaskDueDate'; -import { usePrevious } from '../../hooks/usePrevious'; -import { ConditionsBanner } from './ConditionsBanner'; -import { getUserFullName } from '../../utils/users'; +import { TemplateEditLayout } from './TemplateEditLayout'; +import { useTemplateEditInit } from './useTemplateEditInit'; +import { useTemplateEditTasks } from './useTemplateEditTasks'; import { getSubscriptionPlan } from '../../redux/selectors/user'; import { ESubscriptionPlan } from '../../types/account'; -import { TemplateSettings } from './TemplateSettings'; +import { ETemplateStatus } from '../../types/redux'; import { TemplateForm, useTemplateForm } from './useTemplateForm'; -import styles from './TemplateEdit.css'; -import { getEmptyConditions } from './TaskForm/Conditions/utils/getEmptyConditions'; +import { ITemplateEditParams, TTemplateEditProps } from './templateEditPage.types'; -export interface ITemplateEditProps { - match: any; - location: any; - authUser: IAuthUser; - template: ITemplate; - aiTemplate: ITemplate | null; - templateStatus: ETemplateStatus; - users: TUserListItem[]; - isSubscribed: boolean; - loadTemplate(id: number): void; - loadTemplateFromSystem(id: string): void; - resetTemplateStore(): void; - saveTemplate(): void; - setTemplate(payload: ITemplate): void; - setTemplateStatus(status: ETemplateStatus): void; - loadTemplateVariablesSuccess(payload: TLoadTemplateVariablesSuccessPayload): void; -} - -export type TTemplateEditProps = ITemplateEditProps & RouteComponentProps; - -export interface ITemplateEditParams { - id: string; -} - -export interface ITemplateEditState { - isInfoWarningsModaOpen: boolean; - infoWarnings: ((props: IInfoWarningProps) => JSX.Element)[]; - openedTasks: { [key: string]: boolean }; - openedDelays: { [key: string]: boolean }; -} +export * from './templateEditPage.types'; export function TemplateEdit({ match, @@ -91,360 +36,37 @@ export function TemplateEdit({ template, templateIdentityKey, ); - const { tasks } = formik.values; const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; const accessConditions = isSubscribed || isFreePlan; - const prevUsers = usePrevious(users); - const prevLocation = usePrevious(location); - const prevTemplate = usePrevious(template); - - const [openedTasks, setOpenedTasks] = useState({}); - const [openedDelays, setOpenedDelays] = useState({}); - - useEffect(() => { - initPage(); - - return () => { - resetTemplateStore(); - }; - }, []); - - useEffect(() => { - if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { - openTask(template.tasks[0]?.uuid); - } - }, [template.tasks, prevLocation?.pathname]); - - useEffect(() => { - const variables = getVariables(template); - const prevVariables = prevTemplate ? getVariables(prevTemplate) : []; - if (variables.length !== prevVariables.length) { - if (template.id) { - loadTemplateVariablesSuccess({ templateId: template.id, variables }); - } - } - - const [pathName, prevPathName] = [location.pathname, prevLocation?.pathname]; - const isPreviousPathIsCreate = prevPathName === ERoutes.TemplatesCreate; - const isCurrentPathIsEdit = checkSomeRouteIsActive(ERoutes.TemplatesEdit); - const isCreateScenario = isPreviousPathIsCreate && isCurrentPathIsEdit; - const isLocationChanged = pathName !== prevPathName; - - const isFirstRender = !prevLocation && !prevTemplate && !prevUsers; - if (!isCreateScenario && isLocationChanged) { - if (!isFirstRender) { - initPage(); - } - return; - } - - if (users.length !== prevUsers?.length) { - // Build from the current Formik values, not the Redux `template` prop. - // Field edits live in Formik until `TemplateFormPersistProvider` flushes - // them to Redux, so the Redux snapshot can lag behind. Formik uses - // `enableReinitialize`, so a `setTemplate` built from the stale Redux - // prop would reset Formik to that snapshot and discard any uncommitted - // edits. Spreading from `formik.values` carries those edits into the - // new Redux state so the reinitialize is a no-op for them. - const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, accessConditions, users); - setTemplate({ ...formik.values, owners: newTemplateOwners }); - } - }, [prevTemplate, prevLocation, prevUsers]); - - const initPage = () => { - const { id } = match.params as ITemplateEditParams; - const workflowTemplateId = getTemplateIdFromUrl(location.search); - const isCreateWorflowPage = isCreateTemplate(); - const isEditWorkflow = Boolean(id); - const initMap = [ - { - check: isCreateWorflowPage && workflowTemplateId, - init: () => loadTemplateFromSystem(workflowTemplateId!), - }, - { - check: checkSomeRouteIsActive(ERoutes.TemplatesCreateAI), - init: () => { - const templateLocal = aiTemplate || getEmptyTemplate(); - setTemplate(templateLocal); - saveTemplate(); - }, - name: '2', - }, - { - check: isCreateWorflowPage && !workflowTemplateId, - init: () => setTemplate(getEmptyTemplate()), - }, - { - check: isEditWorkflow, - init: () => loadTemplate(Number(id)), - }, - ]; - const currentPageInit = initMap.find(({ check }) => check); - - if (currentPageInit) { - currentPageInit.init(); - } - }; - - const openTask = (taskUUID?: string) => { - if (!taskUUID) return; - setOpenedTasks({ ...openedTasks, [taskUUID]: true }); - }; - - const sortedTasks = () => [...tasks].sort((a, b) => a.number - b.number); - - const getNewTask = (templateTask?: Partial): ITemplateTask => { - const taskApiName = createTaskApiName(); - - return { - apiName: taskApiName, - delay: null, - description: '', - name: 'New Step', - number: 1, - fields: [], - rawPerformers: [ - { - apiName: createPerformerApiName(), - label: getUserFullName(authUser), - type: ETaskPerformerType.User, - sourceId: String(authUser.id), - }, - ], - uuid: createUUID(), - requireCompletionByAll: false, - skipForStarter: false, - conditions: getEmptyConditions(accessConditions), - rawDueDate: createEmptyTaskDueDate(taskApiName), - checklists: [], - ...templateTask, - revertTask: null, - ancestors: [], - }; - }; - - const getEmptyTemplate = () => { - return { - description: '', - kickoff: getEmptyKickoff(), - name: DEFAULT_TEMPLATE_NAME, - tasks: [ - getNewTask({ - name: 'First Step', - number: 1, - conditions: getKickoffConditions(), - }), - ], - isActive: false, - finalizable: false, - dateUpdated: null, - updatedBy: null, - isPublic: false, - publicUrl: null, - publicSuccessUrl: null, - isEmbedded: false, - embedUrl: null, - tasksCount: 1, - performersCount: 0, - owners: getNormalizedTemplateOwners( - [ - { - sourceId: String(authUser.id), - type: ETemplateOwnerType.User, - apiName: createOwnerApiName(), - role: ETemplateOwnerRole.Owner, - }, - ], - accessConditions, - users, - ), - wfNameTemplate: '{{date}} — {{template-name}}', - } as ITemplate; - }; - - const changeTasks = (newTasks: ITemplateTask[]) => { - setFieldValue('tasks', newTasks, false); - }; - - const handleRemoveTask = (targetTask: ITemplateTask) => () => { - const newTasks = tasks - .filter((task) => task.uuid !== targetTask.uuid) - .map((task, index) => ({ ...task, number: index + 1 })); - - if (!isArrayWithItems(newTasks)) { - changeTasks([getNewTask()]); - - return; - } - - changeTasks(newTasks); - }; - - const handleAddTask = () => { - if (!isArrayWithItems(tasks)) { - const newTasks = [ - getNewTask({ - conditions: getKickoffConditions(), - }), - ]; - - changeTasks(newTasks); - - return; - } - - const newTaskNumber = tasks.length + 1; - const newTask = getNewTask({ - number: newTaskNumber, - name: `New Step ${newTaskNumber}`, - conditions: getStartTaskConditions(tasks[tasks.length - 1].apiName), - }); - const newTasks = [...tasks, newTask]; - - toggleIsOpenTask(newTask.uuid); - changeTasks(newTasks); - }; - - const getTasksWithNewTask = (newTask: ITemplateTask, newTaskIndex: number) => { - const newTasks = [...tasks.slice(0, newTaskIndex), newTask, ...tasks.slice(newTaskIndex)].map((task, index) => ({ - ...task, - number: index + 1, - })); - - return newTasks; - }; - - const handleCloneTask = (targetTask: ITemplateTask) => () => { - const newTask = getClonedTask(targetTask); - const newTasks = getTasksWithNewTask(newTask, targetTask.number); - changeTasks(newTasks); - toggleIsOpenTask(newTask.uuid); - }; - - const handleAddTaskBefore = (targetTask: ITemplateTask) => (previousTaskApiName?: string) => { - const newTaskName = `New Step ${tasks.length + 1}`; - const newTask = getNewTask({ - name: newTaskName, - conditions: previousTaskApiName ? getStartTaskConditions(previousTaskApiName) : getKickoffConditions(), - }); - const newTasks = getTasksWithNewTask(newTask, targetTask.number - 1); - - changeTasks(newTasks); - toggleIsOpenTask(newTask.uuid); - }; - - const toggleIsOpenTask = (taskUUID: string) => { - const isTaskOpen = Boolean(openedTasks[taskUUID]); - - setOpenedTasks({ ...openedTasks, [taskUUID]: !isTaskOpen }); - }; - - const handleMoveTask = (from: number, direction: EMoveDirections) => () => { - const to = direction === EMoveDirections.Up ? from - 1 : from + 1; - const movedTasks = moveTask(from, to, tasks); - const sortedTasksLocal = [...movedTasks].sort((a, b) => a.number - b.number); - - changeTasks(sortedTasksLocal); - }; - - const handleEditTaskField = - (targetTask: ITemplateTask) => (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => { - const newTasks = tasks.map((task) => { - if (targetTask.uuid === task.uuid) { - return { - ...targetTask, - [field]: value, - }; - } - - return task; - }); - - changeTasks(newTasks); - }; - - const addDelay = (targetTask: ITemplateTask) => () => { - if (targetTask.delay) { - const message = formatMessage({ id: 'template.delay-task-has-delay-error' }); - NotificationManager.warning({ message }); - - return; - } - - if (targetTask.number === 1) { - const message = formatMessage({ id: 'template.delay-first-task-delay-error' }); - NotificationManager.warning({ message }); - - return; - } - - const newTasks = tasks.map((task) => { - if (task.uuid === targetTask.uuid) { - return { - ...task, - delay: START_DURATION, - }; - } - - return task; - }); - - toggleDelay(targetTask.uuid); - changeTasks(newTasks); - }; - - const editDelay = (targetTask: ITemplateTask) => (delay: string) => { - const newTasks = tasks.map((task) => { - if (task.uuid === targetTask.uuid) return { ...targetTask, delay }; - - return task; - }); - - changeTasks(newTasks); - }; - - const deleteDelay = (targetTask: ITemplateTask) => () => { - if (!targetTask.delay) return; - - handleEditTaskField(targetTask)('delay')(''); - }; - - const toggleDelay = (taskUUID: string) => { - const isDelayOpen = Boolean(openedDelays[taskUUID]); - - setOpenedDelays({ ...openedDelays, [taskUUID]: !isDelayOpen }); - }; - - const getTaskListItem = (task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]) => { - const isTaskOpen = Boolean(openedTasks[task.uuid]); - const isDelayOpen = Boolean(openedDelays[task.uuid]); - const previousTask = index > 0 ? tasksLocal[index - 1] : null; - const actualPreviousTaskApiName = previousTask?.apiName; - return ( - toggleDelay(task.uuid)} - handleMoveTask={handleMoveTask} - toggleIsOpenTask={() => toggleIsOpenTask(task.uuid)} - actualPreviousTaskApiName={actualPreviousTaskApiName} - /> - ); - }; + const { sortedTasks, handleAddTask, getTaskListItem, openTask } = useTemplateEditTasks({ + authUser, + formik, + setFieldValue, + users, + accessConditions, + formatMessage, + isSubscribed, + }); + + useTemplateEditInit({ + match, + location, + template, + aiTemplate, + users, + accessConditions, + authUser, + formik, + openTask, + loadTemplate, + loadTemplateFromSystem, + resetTemplateStore, + saveTemplate, + setTemplate, + loadTemplateVariablesSuccess, + }); if (templateStatus === ETemplateStatus.Loading) { return
; @@ -460,33 +82,13 @@ export function TemplateEdit({ pendingUserEditsRef={pendingUserEditsRef} persistBaselineSyncRef={persistBaselineSyncRef} > -
- - -
-
- -
-
- {!accessConditions && } -
-
- -
- {sortedTasks().map(getTaskListItem)} - - -
-
-
-
+ ); } diff --git a/frontend/src/public/components/TemplateEdit/TemplateEditLayout.tsx b/frontend/src/public/components/TemplateEdit/TemplateEditLayout.tsx new file mode 100644 index 000000000..1e3f5896c --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TemplateEditLayout.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; + +import { AutoSaveStatusContainer } from './AutoSaveStatus'; +import { TemplateEntity } from './TemplateEntity'; +import { AddEntityButton, EEntityTitle } from './AddEntityButton'; +import { TemplateIntegrations } from './Integrations'; +import { KickoffReduxContainer } from './KickoffRedux'; +import { ConditionsBanner } from './ConditionsBanner'; +import { TemplateSettings } from './TemplateSettings'; +import { ITemplateTask } from '../../types/template'; + +import styles from './TemplateEdit.css'; + +type TTaskListItemProps = React.ComponentProps & { key: string }; + +interface ITemplateEditLayoutProps { + accessConditions: boolean; + saveTemplate(): void; + sortedTasks(): ITemplateTask[]; + getTaskListItem(task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]): TTaskListItemProps; + handleAddTask(): void; +} + +export function TemplateEditLayout({ + accessConditions, + saveTemplate, + sortedTasks, + getTaskListItem, + handleAddTask, +}: ITemplateEditLayoutProps) { + const tasksLocal = sortedTasks(); + + return ( +
+ + +
+
+ +
+
+ {!accessConditions && } +
+
+ +
+ {tasksLocal.map((task, index) => { + const { key, ...taskProps } = getTaskListItem(task, index, tasksLocal); + return ; + })} + + +
+
+
+
+ ); +} diff --git a/frontend/src/public/components/TemplateEdit/templateEditPage.types.ts b/frontend/src/public/components/TemplateEdit/templateEditPage.types.ts new file mode 100644 index 000000000..912b7c554 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/templateEditPage.types.ts @@ -0,0 +1,38 @@ +import { RouteComponentProps } from 'react-router-dom'; + +import { IInfoWarningProps } from './InfoWarningsModal'; +import { TUserListItem } from '../../types/user'; +import { ITemplate } from '../../types/template'; +import { TLoadTemplateVariablesSuccessPayload } from '../../redux/actions'; +import { ETemplateStatus, IAuthUser } from '../../types/redux'; + +export interface ITemplateEditProps { + match: any; + location: any; + authUser: IAuthUser; + template: ITemplate; + aiTemplate: ITemplate | null; + templateStatus: ETemplateStatus; + users: TUserListItem[]; + isSubscribed: boolean; + loadTemplate(id: number): void; + loadTemplateFromSystem(id: string): void; + resetTemplateStore(): void; + saveTemplate(): void; + setTemplate(payload: ITemplate): void; + setTemplateStatus(status: ETemplateStatus): void; + loadTemplateVariablesSuccess(payload: TLoadTemplateVariablesSuccessPayload): void; +} + +export type TTemplateEditProps = ITemplateEditProps & RouteComponentProps; + +export interface ITemplateEditParams { + id: string; +} + +export interface ITemplateEditState { + isInfoWarningsModaOpen: boolean; + infoWarnings: ((props: IInfoWarningProps) => JSX.Element)[]; + openedTasks: { [key: string]: boolean }; + openedDelays: { [key: string]: boolean }; +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts new file mode 100644 index 000000000..d0b8680a6 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -0,0 +1,141 @@ +import { useEffect } from 'react'; +import { FormikProps } from 'formik'; + +import { ITemplateEditParams } from './templateEditPage.types'; +import { createEmptyTemplate } from './utils/createTemplateEditTask'; +import { getVariables } from './TaskForm/utils/getTaskVariables'; +import { ERoutes } from '../../constants/routes'; +import { getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template'; +import { checkSomeRouteIsActive, isCreateTemplate } from '../../utils/history'; +import { usePrevious } from '../../hooks/usePrevious'; +import { ITemplate } from '../../types/template'; +import { TUserListItem } from '../../types/user'; +import { TLoadTemplateVariablesSuccessPayload } from '../../redux/actions'; +import { IAuthUser } from '../../types/redux'; + +type TUseTemplateEditInitParams = { + match: { params: ITemplateEditParams }; + location: { pathname: string; search: string }; + template: ITemplate; + aiTemplate: ITemplate | null; + users: TUserListItem[]; + accessConditions: boolean; + authUser: IAuthUser; + formik: FormikProps; + openTask(taskUUID?: string): void; + loadTemplate(id: number): void; + loadTemplateFromSystem(id: string): void; + resetTemplateStore(): void; + saveTemplate(): void; + setTemplate(payload: ITemplate): void; + loadTemplateVariablesSuccess(payload: TLoadTemplateVariablesSuccessPayload): void; +}; + +export function useTemplateEditInit({ + match, + location, + template, + aiTemplate, + users, + accessConditions, + authUser, + formik, + openTask, + loadTemplate, + loadTemplateFromSystem, + resetTemplateStore, + saveTemplate, + setTemplate, + loadTemplateVariablesSuccess, +}: TUseTemplateEditInitParams) { + const prevUsers = usePrevious(users); + const prevLocation = usePrevious(location); + const prevTemplate = usePrevious(template); + const prevFormikValues = usePrevious(formik.values); + + const initPage = () => { + const { id } = match.params; + const workflowTemplateId = getTemplateIdFromUrl(location.search); + const isCreateWorflowPage = isCreateTemplate(); + const isEditWorkflow = Boolean(id); + const initMap = [ + { + check: isCreateWorflowPage && workflowTemplateId, + init: () => loadTemplateFromSystem(workflowTemplateId!), + }, + { + check: checkSomeRouteIsActive(ERoutes.TemplatesCreateAI), + init: () => { + const templateLocal = aiTemplate || createEmptyTemplate(authUser, users, accessConditions); + setTemplate(templateLocal); + saveTemplate(); + }, + }, + { + check: isCreateWorflowPage && !workflowTemplateId, + init: () => setTemplate(createEmptyTemplate(authUser, users, accessConditions)), + }, + { + check: isEditWorkflow, + init: () => loadTemplate(Number(id)), + }, + ]; + const currentPageInit = initMap.find(({ check }) => check); + + if (currentPageInit) { + currentPageInit.init(); + } + }; + + useEffect(() => { + initPage(); + + return () => { + resetTemplateStore(); + }; + }, []); + + useEffect(() => { + if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { + openTask(template.tasks[0]?.uuid); + } + }, [template.tasks, prevLocation?.pathname]); + + useEffect(() => { + // Derive variables from Formik, not the Redux `template` prop. Kickoff/task + // field edits update Formik immediately while Redux stays stale until autosave. + const variables = getVariables(formik.values); + const prevVariables = prevFormikValues ? getVariables(prevFormikValues) : []; + if (variables.length !== prevVariables.length) { + if (formik.values.id) { + loadTemplateVariablesSuccess({ templateId: formik.values.id, variables }); + } + } + + const [pathName, prevPathName] = [location.pathname, prevLocation?.pathname]; + const isPreviousPathIsCreate = prevPathName === ERoutes.TemplatesCreate; + const isCurrentPathIsEdit = checkSomeRouteIsActive(ERoutes.TemplatesEdit); + const isCreateScenario = isPreviousPathIsCreate && isCurrentPathIsEdit; + const isLocationChanged = pathName !== prevPathName; + + const isFirstRender = !prevLocation && !prevTemplate && !prevUsers; + if (!isCreateScenario && isLocationChanged) { + if (!isFirstRender) { + initPage(); + } + return; + } + + if (users.length !== prevUsers?.length) { + // Build from the current Formik values, not the Redux `template` prop. + // Field edits live in Formik until `TemplateFormPersistProvider` flushes + // them to Redux, so the Redux snapshot can lag behind. Formik uses + // `enableReinitialize`, so a `setTemplate` built from the stale Redux + // prop would reset Formik to that snapshot and discard any uncommitted + // edits. Spreading from `formik.values` carries those edits into the + // new Redux state so the reinitialize is a no-op for them. + const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, accessConditions, users); + setTemplate({ ...formik.values, owners: newTemplateOwners }); + } + }, [prevFormikValues, prevTemplate, prevLocation, prevUsers]); +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts new file mode 100644 index 000000000..435e3f2be --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import { FormikProps } from 'formik'; +import { IntlShape } from 'react-intl'; + +import { createNewTemplateTask } from './utils/createTemplateEditTask'; +import { getClonedTask } from './utils/getClonedTask'; +import { getKickoffConditions } from './TaskForm/Conditions/utils/getKickoffConditions'; +import { getStartTaskConditions } from './TaskForm/Conditions/utils/getStartTaskConditions'; +import { START_DURATION } from './constants'; +import { moveTask } from '../../utils/workflows'; +import { NotificationManager } from '../UI/Notifications'; +import { isArrayWithItems } from '../../utils/helpers'; +import { EMoveDirections } from '../../types/workflow'; +import { ITemplate, ITemplateTask } from '../../types/template'; +import { TUserListItem } from '../../types/user'; +import { IAuthUser } from '../../types/redux'; +import { TSetFieldValue } from './useTemplateForm/types'; + +type TUseTemplateEditTasksParams = { + authUser: IAuthUser; + formik: FormikProps; + setFieldValue: TSetFieldValue; + users: TUserListItem[]; + accessConditions: boolean; + formatMessage: IntlShape['formatMessage']; + isSubscribed: boolean; +}; + +export function useTemplateEditTasks({ + authUser, + formik, + setFieldValue, + accessConditions, + formatMessage, + isSubscribed, + users, +}: TUseTemplateEditTasksParams) { + const { tasks } = formik.values; + const [openedTasks, setOpenedTasks] = useState>({}); + const [openedDelays, setOpenedDelays] = useState>({}); + + const changeTasks = (newTasks: ITemplateTask[]) => { + setFieldValue('tasks', newTasks, false); + }; + + const openTask = (taskUUID?: string) => { + if (!taskUUID) return; + setOpenedTasks((prev) => ({ ...prev, [taskUUID]: true })); + }; + + const toggleIsOpenTask = (taskUUID: string) => { + setOpenedTasks((prev) => ({ ...prev, [taskUUID]: !prev[taskUUID] })); + }; + + const toggleDelay = (taskUUID: string) => { + setOpenedDelays((prev) => ({ ...prev, [taskUUID]: !prev[taskUUID] })); + }; + + const sortedTasks = () => [...tasks].sort((a, b) => a.number - b.number); + + const getNewTask = (templateTask?: Partial) => + createNewTemplateTask(authUser, accessConditions, templateTask); + + const handleRemoveTask = (targetTask: ITemplateTask) => () => { + const newTasks = tasks + .filter((task) => task.uuid !== targetTask.uuid) + .map((task, index) => ({ ...task, number: index + 1 })); + + if (!isArrayWithItems(newTasks)) { + changeTasks([getNewTask()]); + return; + } + + changeTasks(newTasks); + }; + + const handleAddTask = () => { + if (!isArrayWithItems(tasks)) { + changeTasks([ + getNewTask({ + conditions: getKickoffConditions(), + }), + ]); + return; + } + + const newTaskNumber = tasks.length + 1; + const newTask = getNewTask({ + number: newTaskNumber, + name: `New Step ${newTaskNumber}`, + conditions: getStartTaskConditions(tasks[tasks.length - 1].apiName), + }); + + toggleIsOpenTask(newTask.uuid); + changeTasks([...tasks, newTask]); + }; + + const getTasksWithNewTask = (newTask: ITemplateTask, newTaskIndex: number) => + [...tasks.slice(0, newTaskIndex), newTask, ...tasks.slice(newTaskIndex)].map((task, index) => ({ + ...task, + number: index + 1, + })); + + const handleCloneTask = (targetTask: ITemplateTask) => () => { + const newTask = getClonedTask(targetTask); + changeTasks(getTasksWithNewTask(newTask, targetTask.number)); + toggleIsOpenTask(newTask.uuid); + }; + + const handleAddTaskBefore = (targetTask: ITemplateTask) => (previousTaskApiName?: string) => { + const newTask = getNewTask({ + name: `New Step ${tasks.length + 1}`, + conditions: previousTaskApiName ? getStartTaskConditions(previousTaskApiName) : getKickoffConditions(), + }); + + changeTasks(getTasksWithNewTask(newTask, targetTask.number - 1)); + toggleIsOpenTask(newTask.uuid); + }; + + const handleMoveTask = (from: number, direction: EMoveDirections) => () => { + const to = direction === EMoveDirections.Up ? from - 1 : from + 1; + const movedTasks = moveTask(from, to, tasks); + changeTasks([...movedTasks].sort((a, b) => a.number - b.number)); + }; + + const handleEditTaskField = + (targetTask: ITemplateTask) => (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => { + changeTasks( + tasks.map((task) => (targetTask.uuid === task.uuid ? { ...targetTask, [field]: value } : task)), + ); + }; + + const addDelay = (targetTask: ITemplateTask) => () => { + if (targetTask.delay) { + NotificationManager.warning({ + message: formatMessage({ id: 'template.delay-task-has-delay-error' }), + }); + return; + } + + if (targetTask.number === 1) { + NotificationManager.warning({ + message: formatMessage({ id: 'template.delay-first-task-delay-error' }), + }); + return; + } + + toggleDelay(targetTask.uuid); + changeTasks( + tasks.map((task) => (task.uuid === targetTask.uuid ? { ...task, delay: START_DURATION } : task)), + ); + }; + + const editDelay = (targetTask: ITemplateTask) => (delay: string) => { + changeTasks(tasks.map((task) => (task.uuid === targetTask.uuid ? { ...targetTask, delay } : task))); + }; + + const deleteDelay = (targetTask: ITemplateTask) => () => { + if (!targetTask.delay) return; + handleEditTaskField(targetTask)('delay')(''); + }; + + const getTaskListItem = (task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]) => { + const previousTask = index > 0 ? tasksLocal[index - 1] : null; + + return { + key: `template-entity-${task.uuid}`, + index, + task, + users, + tasksCount: tasksLocal.length, + isSubscribed, + removeTask: handleRemoveTask(task), + cloneTask: handleCloneTask(task), + addDelay: addDelay(task), + addTaskBefore: handleAddTaskBefore(task), + deleteDelay, + editDelay: editDelay(task), + isTaskOpen: Boolean(openedTasks[task.uuid]), + isDelayOpen: Boolean(openedDelays[task.uuid]), + toggleDelay: () => toggleDelay(task.uuid), + handleMoveTask, + toggleIsOpenTask: () => toggleIsOpenTask(task.uuid), + actualPreviousTaskApiName: previousTask?.apiName, + }; + }; + + return { + sortedTasks, + handleAddTask, + getTaskListItem, + openTask, + }; +} diff --git a/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts new file mode 100644 index 000000000..82b1ef28d --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts @@ -0,0 +1,93 @@ +import { DEFAULT_TEMPLATE_NAME } from '../constants'; +import { getKickoffConditions } from '../TaskForm/Conditions/utils/getKickoffConditions'; +import { getEmptyConditions } from '../TaskForm/Conditions/utils/getEmptyConditions'; +import { createEmptyTaskDueDate } from '../../../utils/dueDate/createEmptyTaskDueDate'; +import { getEmptyKickoff, getNormalizedTemplateOwners } from '../../../utils/template'; +import { createOwnerApiName, createPerformerApiName, createTaskApiName, createUUID } from '../../../utils/createId'; +import { getUserFullName } from '../../../utils/users'; +import { + ETaskPerformerType, + ETemplateOwnerRole, + ETemplateOwnerType, + ITemplate, + ITemplateTask, +} from '../../../types/template'; +import { TUserListItem } from '../../../types/user'; +import { IAuthUser } from '../../../types/redux'; + +export function createNewTemplateTask( + authUser: IAuthUser, + accessConditions: boolean, + templateTask?: Partial, +): ITemplateTask { + const taskApiName = createTaskApiName(); + + return { + apiName: taskApiName, + delay: null, + description: '', + name: 'New Step', + number: 1, + fields: [], + rawPerformers: [ + { + apiName: createPerformerApiName(), + label: getUserFullName(authUser), + type: ETaskPerformerType.User, + sourceId: String(authUser.id), + }, + ], + uuid: createUUID(), + requireCompletionByAll: false, + skipForStarter: false, + conditions: getEmptyConditions(accessConditions), + rawDueDate: createEmptyTaskDueDate(taskApiName), + checklists: [], + ...templateTask, + revertTask: null, + ancestors: [], + }; +} + +export function createEmptyTemplate( + authUser: IAuthUser, + users: TUserListItem[], + accessConditions: boolean, +): ITemplate { + return { + description: '', + kickoff: getEmptyKickoff(), + name: DEFAULT_TEMPLATE_NAME, + tasks: [ + createNewTemplateTask(authUser, accessConditions, { + name: 'First Step', + number: 1, + conditions: getKickoffConditions(), + }), + ], + isActive: false, + finalizable: false, + dateUpdated: null, + updatedBy: null, + isPublic: false, + publicUrl: null, + publicSuccessUrl: null, + isEmbedded: false, + embedUrl: null, + tasksCount: 1, + performersCount: 0, + owners: getNormalizedTemplateOwners( + [ + { + sourceId: String(authUser.id), + type: ETemplateOwnerType.User, + apiName: createOwnerApiName(), + role: ETemplateOwnerRole.Owner, + }, + ], + accessConditions, + users, + ), + wfNameTemplate: '{{date}} — {{template-name}}', + } as ITemplate; +} From e49f342a4f4a163c2fd1ddf3e72b5f88255bfd98 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 16:32:57 +0300 Subject: [PATCH 26/39] 59876 fix(template): harden Formik persist retry, revert, and init edge cases --- .../components/TemplateEdit/TemplateEdit.tsx | 2 +- .../TemplateEdit/TemplateEditLayout.tsx | 6 +- .../__tests__/useTemplateForm.test.tsx | 161 +++++++++++++++++- .../TemplateEdit/useTemplateEditInit.ts | 8 +- .../useTemplateForm/TemplateForm.tsx | 1 - .../TemplateFormPersistProvider.tsx | 39 +++-- .../TemplateEdit/useTemplateForm/index.ts | 1 + .../templateFormDeactivation.ts | 12 +- .../useTemplateForm/useTemplateSaveRetry.ts | 38 +++++ .../__tests__/createTemplateEditTask.test.ts | 17 ++ .../utils/createTemplateEditTask.ts | 11 +- frontend/src/public/redux/template/saga.ts | 11 +- frontend/src/public/utils/template.ts | 18 ++ 13 files changed, 293 insertions(+), 32 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts create mode 100644 frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 9fa78640b..4d3e452ee 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -57,6 +57,7 @@ export function TemplateEdit({ aiTemplate, users, accessConditions, + isSubscribed, authUser, formik, openTask, @@ -84,7 +85,6 @@ export function TemplateEdit({ > & { key: s interface ITemplateEditLayoutProps { accessConditions: boolean; - saveTemplate(): void; sortedTasks(): ITemplateTask[]; getTaskListItem(task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]): TTaskListItemProps; handleAddTask(): void; @@ -23,16 +23,16 @@ interface ITemplateEditLayoutProps { export function TemplateEditLayout({ accessConditions, - saveTemplate, sortedTasks, getTaskListItem, handleAddTask, }: ITemplateEditLayoutProps) { const tasksLocal = sortedTasks(); + const retryFailedSave = useTemplateSaveRetry(); return (
- +
diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index a9d461ef9..0b076d695 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -8,17 +8,20 @@ import { ETaskPerformerType, } from '../../../types/template'; import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../TaskForm/Conditions/types'; -import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist } from '../useTemplateForm'; -import { patchTemplate } from '../../../redux/actions'; +import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist, useTemplateSaveRetry } from '../useTemplateForm'; +import { patchTemplate, saveTemplate } from '../../../redux/actions'; + +const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ - useDispatch: jest.fn(() => jest.fn()), + useDispatch: jest.fn(() => mockDispatch), connect: () => (component: unknown) => component, })); jest.mock('../../../redux/actions', () => ({ ...jest.requireActual('../../../redux/actions'), patchTemplate: jest.fn((payload: unknown) => ({ type: 'PATCH_TEMPLATE', payload })), + saveTemplate: jest.fn((payload?: unknown) => ({ type: 'SAVE_TEMPLATE', payload })), })); const makeTask = (overrides: Partial = {}): ITemplateTask => @@ -197,6 +200,24 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(handle!.values.isActive).toBe(false); }); + it('deactivates when kickoff fields change even if description also changes', async () => { + const template = makeTemplate({ + isActive: true, + kickoff: { description: 'old kickoff', fields: [{ apiName: 'f-1' }] } as any, + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('kickoff', { description: 'new kickoff', fields: [] }, false); + }); + + await flushPersist(); + + expect(handle!.values.isActive).toBe(false); + }); + it('lets submit consume pending edits before the autosave dispatches', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; @@ -265,6 +286,50 @@ describe('TemplateFormPersistProvider deactivation', () => { }); }); + it('re-queues autosave after a failed explicit submit when Redux reinitializes during the in-flight patch', async () => { + const template = makeTemplate({ isActive: true, owners: [] }); + let handle: ISpyHandle | null = null; + const owners = [{ id: 1, role: 'owner' }] as any; + + const { rerender } = render( + { handle = h; }} />, + ); + + act(() => { + handle!.setFieldValue('owners', owners, false); + }); + + act(() => { + handle!.consumePendingChanges(); + }); + + act(() => { + rerender( + { handle = h; }} + />, + ); + }); + + expect(handle!.values.owners).toEqual(owners); + + act(() => { + handle!.revertConsumedChanges(); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + owners, + isActive: false, + }); + }); + it('does not restore the persist baseline after an explicit submit succeeds', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; @@ -1096,3 +1161,93 @@ describe('useTemplateForm reference cleanup', () => { expect(patchTemplate).not.toHaveBeenCalled(); }); }); + +describe('useTemplateSaveRetry', () => { + beforeEach(() => { + mockDispatch.mockClear(); + (patchTemplate as unknown as jest.Mock).mockClear(); + (saveTemplate as unknown as jest.Mock).mockClear(); + }); + + function SaveRetryHarness({ + initialTemplate, + onReady, + }: { + initialTemplate: ITemplate; + onReady(retry: () => void, setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void): void; + }) { + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(initialTemplate); + + const RetrySpy: React.FC = () => { + const { setFieldValue: contextSetFieldValue } = useTemplateField(); + const retryFailedSave = useTemplateSaveRetry(); + onReady(retryFailedSave, contextSetFieldValue); + return null; + }; + + return ( + + + + ); + } + + it('flushes pending Formik edits through patchTemplate instead of saveTemplate', () => { + const template = makeTemplate({ isActive: true, name: 'Original' }); + let retryFailedSave: (() => void) | null = null; + let editField: ((field: string, value: unknown, shouldValidate?: boolean) => void) | null = null; + + render( + { + retryFailedSave = retry; + editField = setFieldValue; + }} + />, + ); + + act(() => { + editField!('name', 'Retry edit', false); + }); + + act(() => { + retryFailedSave!(); + }); + + expect(saveTemplate).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(patchTemplate).toHaveBeenCalledWith({ + changedFields: { name: 'Retry edit', isActive: false }, + onSuccess: expect.any(Function), + onFailed: expect.any(Function), + }); + }); + + it('retries the Redux snapshot when Formik has no unflushed edits', () => { + const template = makeTemplate({ isActive: true, name: 'Original' }); + let retryFailedSave: (() => void) | null = null; + + render( + { retryFailedSave = retry; }} + />, + ); + + act(() => { + retryFailedSave!(); + }); + + expect(patchTemplate).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(saveTemplate).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts index d0b8680a6..cfb3130bc 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -20,6 +20,7 @@ type TUseTemplateEditInitParams = { aiTemplate: ITemplate | null; users: TUserListItem[]; accessConditions: boolean; + isSubscribed: boolean; authUser: IAuthUser; formik: FormikProps; openTask(taskUUID?: string): void; @@ -38,6 +39,7 @@ export function useTemplateEditInit({ aiTemplate, users, accessConditions, + isSubscribed, authUser, formik, openTask, @@ -66,14 +68,14 @@ export function useTemplateEditInit({ { check: checkSomeRouteIsActive(ERoutes.TemplatesCreateAI), init: () => { - const templateLocal = aiTemplate || createEmptyTemplate(authUser, users, accessConditions); + const templateLocal = aiTemplate || createEmptyTemplate(authUser, users, accessConditions, isSubscribed); setTemplate(templateLocal); saveTemplate(); }, }, { check: isCreateWorflowPage && !workflowTemplateId, - init: () => setTemplate(createEmptyTemplate(authUser, users, accessConditions)), + init: () => setTemplate(createEmptyTemplate(authUser, users, accessConditions, isSubscribed)), }, { check: isEditWorkflow, @@ -134,7 +136,7 @@ export function useTemplateEditInit({ // prop would reset Formik to that snapshot and discard any uncommitted // edits. Spreading from `formik.values` carries those edits into the // new Redux state so the reinitialize is a no-op for them. - const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, accessConditions, users); + const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, isSubscribed, users); setTemplate({ ...formik.values, owners: newTemplateOwners }); } }, [prevFormikValues, prevTemplate, prevLocation, prevUsers]); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx index 9dfb71e1f..aea5cb8bf 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx @@ -43,7 +43,6 @@ export function TemplateForm({ dirtyRef={dirtyRef} pendingUserEditsRef={pendingUserEditsRef} persistBaselineSyncRef={persistBaselineSyncRef} - setValues={setValues} > {children} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index 8e2117142..0c3dbdf01 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -7,13 +7,12 @@ import { ITemplate } from '../../../types/template'; import { patchTemplate } from '../../../redux/actions'; import { TemplatePersistContext } from './contexts'; import { getChangedFields, getUnconsumedPendingEdits } from './templateFormUtils'; -import { ITemplatePersistContextValue, TSetValues } from './types'; +import { ITemplatePersistContextValue } from './types'; interface ITemplateFormPersistProviderProps { dirtyRef: React.MutableRefObject; pendingUserEditsRef: React.MutableRefObject>; persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; - setValues: TSetValues; children: React.ReactNode; } @@ -42,7 +41,6 @@ export function TemplateFormPersistProvider({ dirtyRef, pendingUserEditsRef, persistBaselineSyncRef, - setValues, children, }: ITemplateFormPersistProviderProps) { const { values } = useFormikContext(); @@ -53,17 +51,22 @@ export function TemplateFormPersistProvider({ const dirtyRefRef = useRef(dirtyRef); const pendingUserEditsRefRef = useRef(pendingUserEditsRef); const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); - const setValuesRef = useRef(setValues); valuesRef.current = values; dispatchRef.current = dispatch; dirtyRefRef.current = dirtyRef; pendingUserEditsRefRef.current = pendingUserEditsRef; persistBaselineSyncRefRef.current = persistBaselineSyncRef; - setValuesRef.current = setValues; useEffect(() => { persistBaselineSyncRefRef.current.current = (reduxTemplate) => { + // While an explicit submit has consumed pending edits and is waiting for + // confirm/revert, keep the pre-submit baseline so a Redux reinitialize from + // the in-flight patch does not collapse the diff we need to restore. + if (consumedPendingRef.current) { + return; + } + // Baseline must mirror the latest Redux snapshot for every field so // server-stamped or normalized values (dateUpdated, owners, ...) are not // diffed as user edits on the next flush. @@ -98,7 +101,14 @@ export function TemplateFormPersistProvider({ } previousValuesRef.current = valuesRef.current; - dirtyRefRef.current.current = false; + + // Explicit submit flows (activate/deactivate) consume pending edits before + // `patchTemplate` returns. Keep the form dirty until confirm/revert so a + // Redux reinitialize from the in-flight patch does not reset Formik and + // drop the consumed-but-unsaved edits. + if (!trackForRevert) { + dirtyRefRef.current.current = false; + } if (isUserEdit && Object.keys(changedFields).length > 0 && !trackForRevert) { pendingUserEditsRefRef.current.current = {}; @@ -120,6 +130,10 @@ export function TemplateFormPersistProvider({ } consumedPendingRef.current = null; + + if (Object.keys(pendingUserEditsRefRef.current.current).length === 0) { + dirtyRefRef.current.current = false; + } }, []); const flushPersist = useCallback(() => { @@ -138,15 +152,18 @@ export function TemplateFormPersistProvider({ } previousValuesRef.current = consumed.previousBaseline; + pendingUserEditsRefRef.current.current = { ...consumed.pendingUserEdits }; + dirtyRefRef.current.current = consumed.isUserEdit; consumedPendingRef.current = null; - // Restoring the baseline does not change Formik `values`, so the values - // effect won't re-run. Re-apply the current values through the wrapped - // setter so dirty state, pending edits, and the autosave effect stay aligned. + // Formik `values` are unchanged, so the `[values]` effect will not re-run. + // Re-queue autosave directly against the restored baseline instead of + // relying on `setValues`, which would diff against a Redux snapshot that may + // already include the consumed (but unsaved) fields from the failed patch. if (consumed.isUserEdit && previousValuesRef.current !== valuesRef.current) { - setValuesRef.current({ ...valuesRef.current }, false); + flushPersist(); } - }, []); + }, [flushPersist]); const abandonPendingChanges = useCallback(() => { previousValuesRef.current = valuesRef.current; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts index 26269dfa9..9dc6b2bff 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts @@ -1,3 +1,4 @@ export { TemplateFieldContext, TaskFormScopeProvider, useTaskFormScope, useTemplateField, useTemplatePersist } from './contexts'; export { TemplateForm } from './TemplateForm'; export { useTemplateForm } from './useTemplateFormHook'; +export { useTemplateSaveRetry } from './useTemplateSaveRetry'; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts index 165f75b33..551c963a0 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts @@ -1,4 +1,5 @@ import { ITemplate } from '../../../types/template'; +import { haveSameKickoffFields } from '../../../utils/template'; import { getChangedFields } from './templateFormUtils'; // Keep in sync with `patchTemplateSaga` in redux/template/saga.ts. The saga @@ -22,10 +23,13 @@ function shouldDeactivateTemplate( ); if (Object.keys(changedFields).length === 1 && Object.prototype.hasOwnProperty.call(changedFields, 'kickoff')) { - shouldDeactivate = - changedFields.kickoff?.description === previousTemplate.kickoff.description - ? shouldDeactivate - : false; + const kickoffChanged = changedFields.kickoff; + const previousKickoff = previousTemplate.kickoff; + + if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { + shouldDeactivate = + kickoffChanged?.description === previousKickoff.description ? shouldDeactivate : false; + } } return shouldDeactivate; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts new file mode 100644 index 000000000..ed10f4ec4 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { patchTemplate, saveTemplate } from '../../../redux/actions'; +import { useTemplatePersist } from './contexts'; + +/** + * Retries a failed template save using the current Formik state. + * + * Autosave reads from Redux, but user edits land in Formik first and only reach + * Redux after `TemplateFormPersistProvider` flushes. Retrying with `saveTemplate` + * alone can persist a stale Redux snapshot and drop in-flight Formik edits. + */ +export function useTemplateSaveRetry(): () => void { + const dispatch = useDispatch(); + const { + consumePendingChanges, + confirmConsumedChanges, + revertConsumedChanges, + } = useTemplatePersist(); + + return useCallback(() => { + const pendingChanges = consumePendingChanges(); + + if (Object.keys(pendingChanges).length > 0) { + dispatch( + patchTemplate({ + changedFields: pendingChanges, + onSuccess: confirmConsumedChanges, + onFailed: revertConsumedChanges, + }), + ); + return; + } + + dispatch(saveTemplate()); + }, [consumePendingChanges, confirmConsumedChanges, dispatch, revertConsumedChanges]); +} diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts new file mode 100644 index 000000000..0bff4a14d --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts @@ -0,0 +1,17 @@ +import { IAuthUser } from '../../../../types/redux'; +import { createNewTemplateTask } from '../createTemplateEditTask'; + +const authUser = { + id: 1, + firstName: 'Test', + lastName: 'User', +} as IAuthUser; + +describe('createNewTemplateTask', () => { + it('sets rawDueDate.sourceId from the final apiName when caller overrides apiName', () => { + const task = createNewTemplateTask(authUser, true, { apiName: 'custom-task-api-name' }); + + expect(task.apiName).toBe('custom-task-api-name'); + expect(task.rawDueDate.sourceId).toBe('custom-task-api-name'); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts index 82b1ef28d..fdfbda9ef 100644 --- a/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts +++ b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts @@ -22,7 +22,7 @@ export function createNewTemplateTask( ): ITemplateTask { const taskApiName = createTaskApiName(); - return { + const task = { apiName: taskApiName, delay: null, description: '', @@ -41,18 +41,23 @@ export function createNewTemplateTask( requireCompletionByAll: false, skipForStarter: false, conditions: getEmptyConditions(accessConditions), - rawDueDate: createEmptyTaskDueDate(taskApiName), checklists: [], ...templateTask, revertTask: null, ancestors: [], }; + + return { + ...task, + rawDueDate: createEmptyTaskDueDate(task.apiName), + }; } export function createEmptyTemplate( authUser: IAuthUser, users: TUserListItem[], accessConditions: boolean, + isSubscribed: boolean, ): ITemplate { return { description: '', @@ -85,7 +90,7 @@ export function createEmptyTemplate( role: ETemplateOwnerRole.Owner, }, ], - accessConditions, + isSubscribed, users, ), wfNameTemplate: '{{date}} — {{template-name}}', diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index d4d221ac6..1d995b762 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -54,7 +54,7 @@ import { ITemplate, ITemplateRequest, ITemplateResponse } from '../../types/temp import { logger } from '../../utils/logger'; import { NotificationManager } from '../../components/UI/Notifications'; import { updateTemplate } from '../../api/updateTemplate'; -import { cleanTemplateReferences, getNormalizedTemplate, mapTemplateRequest } from '../../utils/template'; +import { cleanTemplateReferences, getNormalizedTemplate, haveSameKickoffFields, mapTemplateRequest } from '../../utils/template'; import { getErrorMessage, isPaidFeatureError } from '../../utils/getErrorMessage'; import { insertId } from '../../utils/templates/insertId'; import { ETemplateStatus } from '../../types/redux'; @@ -108,8 +108,13 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }: : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { - shouldDeactivateTemplate = - changedFields.kickoff?.description === template.kickoff.description ? shouldDeactivateTemplate : false; + const kickoffChanged = changedFields.kickoff; + const previousKickoff = template.kickoff; + + if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { + shouldDeactivateTemplate = + kickoffChanged?.description === previousKickoff.description ? shouldDeactivateTemplate : false; + } } const mergedTemplate: ITemplate = { diff --git a/frontend/src/public/utils/template.ts b/frontend/src/public/utils/template.ts index a7f286490..a77fb7985 100644 --- a/frontend/src/public/utils/template.ts +++ b/frontend/src/public/utils/template.ts @@ -89,6 +89,24 @@ export const setPerformersCounts = field === previous[index]); +} + export const getNormalizedTemplateOwners = ( initialTemplateOwners: ITemplate['owners'], isSubscribed: boolean, From 28a33f3cca059c322d76de0e75e91b1c51745132 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 17:01:37 +0300 Subject: [PATCH 27/39] 59876 fix(template): defer autosave commit and fix init/retry failures --- .../TemplateControlls/TemplateControlls.tsx | 4 +- .../__tests__/useTemplateForm.test.tsx | 110 ++++++++++++++++- .../TemplateEdit/useTemplateEditInit.ts | 4 +- .../TemplateFormPersistProvider.tsx | 113 +++++++++++++----- .../TemplateEdit/useTemplateForm/types.ts | 4 +- .../useTemplateForm/useTemplateSaveRetry.ts | 11 +- 6 files changed, 203 insertions(+), 43 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index 0259ba01b..b46cd2432 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -117,7 +117,7 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { - const pendingChanges = consumePendingChanges(); + const pendingChanges = consumePendingChanges({ isActive: false }); patchTemplate({ changedFields: { ...pendingChanges, isActive: false }, @@ -139,7 +139,7 @@ export function TemplateControlls({ setIsTemplateActivating(true); - const pendingChanges = consumePendingChanges(); + const pendingChanges = consumePendingChanges({ isActive: true }); patchTemplate({ changedFields: { diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 0b076d695..3646bfafb 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -72,7 +72,7 @@ const makeTemplate = (overrides: Partial = {}): ITemplate => interface ISpyHandle { values: ITemplate; setFieldValue: (field: string, value: unknown, shouldValidate?: boolean) => void; - consumePendingChanges: () => Partial; + consumePendingChanges: (explicitFields?: Partial) => Partial; confirmConsumedChanges: () => void; revertConsumedChanges: () => void; abandonPendingChanges: () => void; @@ -80,11 +80,23 @@ interface ISpyHandle { // Drain the persist provider's `setTimeout(0)` flush plus the follow-up tick it // schedules when it mirrors the saga's `isActive` deactivation, so no React -// state update leaks outside `act`. -const flushPersist = () => +// state update leaks outside `act`. By default, simulates a successful autosave +// so the persist baseline advances before the next edit. +const flushPersist = (options?: { confirmSuccess?: boolean }) => act(async () => { await new Promise((resolve) => { setTimeout(resolve, 0); }); await new Promise((resolve) => { setTimeout(resolve, 0); }); + + if (options?.confirmSuccess === false) { + return; + } + + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + + if (lastCall?.[0]?.onSuccess) { + lastCall[0].onSuccess(); + } }); function TemplateFormHarness({ @@ -1250,4 +1262,96 @@ describe('useTemplateSaveRetry', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); expect(saveTemplate).toHaveBeenCalled(); }); + + it('retries pending Formik edits after an autosave failure instead of saveTemplate', async () => { + const template = makeTemplate({ isActive: true, name: 'Original' }); + let retryFailedSave: (() => void) | null = null; + let editField: ((field: string, value: unknown, shouldValidate?: boolean) => void) | null = null; + + render( + { + retryFailedSave = retry; + editField = setFieldValue; + }} + />, + ); + + act(() => { + editField!('name', 'Retry edit', false); + }); + + await flushPersist({ confirmSuccess: false }); + + act(() => { + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + lastCall![0].onFailed(); + }); + + mockDispatch.mockClear(); + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + retryFailedSave!(); + }); + + expect(saveTemplate).not.toHaveBeenCalled(); + expect(patchTemplate).toHaveBeenCalledWith({ + changedFields: { name: 'Retry edit', isActive: false }, + onSuccess: expect.any(Function), + onFailed: expect.any(Function), + }); + }); + + it('retries a failed activation with isActive true', () => { + const template = makeTemplate({ isActive: false, name: 'Original' }); + let retryFailedSave: (() => void) | null = null; + let persist: Pick | null = null; + + function ActivationRetryHarness() { + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(template); + + const RetrySpy: React.FC = () => { + retryFailedSave = useTemplateSaveRetry(); + persist = useTemplatePersist(); + return null; + }; + + return ( + + + + ); + } + + render(); + + act(() => { + persist!.consumePendingChanges({ isActive: true }); + persist!.revertConsumedChanges(); + }); + + mockDispatch.mockClear(); + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + retryFailedSave!(); + }); + + expect(saveTemplate).not.toHaveBeenCalled(); + expect(patchTemplate).toHaveBeenCalledWith({ + changedFields: { isActive: true }, + onSuccess: expect.any(Function), + onFailed: expect.any(Function), + }); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts index cfb3130bc..ec1905144 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -101,7 +101,7 @@ export function useTemplateEditInit({ if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { openTask(template.tasks[0]?.uuid); } - }, [template.tasks, prevLocation?.pathname]); + }, [template.tasks, location.pathname]); useEffect(() => { // Derive variables from Formik, not the Redux `template` prop. Kickoff/task @@ -139,5 +139,5 @@ export function useTemplateEditInit({ const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, isSubscribed, users); setTemplate({ ...formik.values, owners: newTemplateOwners }); } - }, [prevFormikValues, prevTemplate, prevLocation, prevUsers]); + }, [location, users, template, formik.values]); } diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index 0c3dbdf01..c92570e3e 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -16,6 +16,13 @@ interface ITemplateFormPersistProviderProps { children: React.ReactNode; } +type TConsumedPending = { + previousBaseline: ITemplate; + isUserEdit: boolean; + pendingUserEdits: Partial; + explicitFields?: Partial; +}; + /** * Single save point for every user edit on the Edit Template page. * @@ -51,6 +58,10 @@ export function TemplateFormPersistProvider({ const dirtyRefRef = useRef(dirtyRef); const pendingUserEditsRefRef = useRef(pendingUserEditsRef); const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); + const consumedPendingRef = useRef(null); + const retryExplicitPatchRef = useRef>({}); + const persistInFlightRef = useRef(false); + const flushPersistRef = useRef<() => void>(() => undefined); valuesRef.current = values; dispatchRef.current = dispatch; @@ -78,13 +89,7 @@ export function TemplateFormPersistProvider({ }; }, []); - const consumedPendingRef = useRef<{ - previousBaseline: ITemplate; - isUserEdit: boolean; - pendingUserEdits: Partial; - } | null>(null); - - const takePendingChanges = useCallback((trackForRevert = false) => { + const takePendingChanges = useCallback((mode: 'flush' | 'consume') => { if (previousValuesRef.current === valuesRef.current) { return {}; } @@ -92,7 +97,7 @@ export function TemplateFormPersistProvider({ const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); const isUserEdit = dirtyRefRef.current.current; - if (trackForRevert && isUserEdit && Object.keys(changedFields).length > 0) { + if (isUserEdit && Object.keys(changedFields).length > 0) { consumedPendingRef.current = { previousBaseline: previousValuesRef.current, isUserEdit, @@ -100,24 +105,16 @@ export function TemplateFormPersistProvider({ }; } - previousValuesRef.current = valuesRef.current; - - // Explicit submit flows (activate/deactivate) consume pending edits before - // `patchTemplate` returns. Keep the form dirty until confirm/revert so a - // Redux reinitialize from the in-flight patch does not reset Formik and - // drop the consumed-but-unsaved edits. - if (!trackForRevert) { - dirtyRefRef.current.current = false; - } - - if (isUserEdit && Object.keys(changedFields).length > 0 && !trackForRevert) { - pendingUserEditsRefRef.current.current = {}; + // Explicit submit flows advance the baseline immediately so the autosave + // effect does not re-dispatch the same diff while the patch is in flight. + if (mode === 'consume') { + previousValuesRef.current = valuesRef.current; } return isUserEdit ? changedFields : {}; }, []); - const consumePendingChanges = useCallback(() => takePendingChanges(true), [takePendingChanges]); + const getRetryExplicitPatch = useCallback(() => retryExplicitPatchRef.current, []); const confirmConsumedChanges = useCallback(() => { const consumed = consumedPendingRef.current; @@ -130,19 +127,17 @@ export function TemplateFormPersistProvider({ } consumedPendingRef.current = null; + retryExplicitPatchRef.current = {}; + previousValuesRef.current = valuesRef.current; if (Object.keys(pendingUserEditsRefRef.current.current).length === 0) { dirtyRefRef.current.current = false; } - }, []); - const flushPersist = useCallback(() => { - const changedFields = takePendingChanges(); - - if (Object.keys(changedFields).length > 0) { - dispatchRef.current(patchTemplate({ changedFields })); + if (previousValuesRef.current !== valuesRef.current) { + flushPersistRef.current(); } - }, [takePendingChanges]); + }, []); const revertConsumedChanges = useCallback(() => { const consumed = consumedPendingRef.current; @@ -154,6 +149,11 @@ export function TemplateFormPersistProvider({ previousValuesRef.current = consumed.previousBaseline; pendingUserEditsRefRef.current.current = { ...consumed.pendingUserEdits }; dirtyRefRef.current.current = consumed.isUserEdit; + + if (consumed.explicitFields && Object.keys(consumed.explicitFields).length > 0) { + retryExplicitPatchRef.current = { ...consumed.explicitFields }; + } + consumedPendingRef.current = null; // Formik `values` are unchanged, so the `[values]` effect will not re-run. @@ -161,19 +161,67 @@ export function TemplateFormPersistProvider({ // relying on `setValues`, which would diff against a Redux snapshot that may // already include the consumed (but unsaved) fields from the failed patch. if (consumed.isUserEdit && previousValuesRef.current !== valuesRef.current) { - flushPersist(); + flushPersistRef.current(); } - }, [flushPersist]); + }, []); + + const consumePendingChanges = useCallback((explicitFields?: Partial) => { + const changedFields = takePendingChanges('consume'); + + if (explicitFields && Object.keys(explicitFields).length > 0) { + if (consumedPendingRef.current) { + consumedPendingRef.current.explicitFields = explicitFields; + } else { + consumedPendingRef.current = { + previousBaseline: previousValuesRef.current, + isUserEdit: false, + pendingUserEdits: {}, + explicitFields, + }; + previousValuesRef.current = valuesRef.current; + } + } + + return changedFields; + }, [takePendingChanges]); + + const flushPersist = useCallback(() => { + if (persistInFlightRef.current) { + return; + } + + const changedFields = takePendingChanges('flush'); + + if (Object.keys(changedFields).length > 0) { + persistInFlightRef.current = true; + dispatchRef.current( + patchTemplate({ + changedFields, + onSuccess: () => { + persistInFlightRef.current = false; + confirmConsumedChanges(); + }, + onFailed: () => { + persistInFlightRef.current = false; + revertConsumedChanges(); + }, + }), + ); + } + }, [takePendingChanges, confirmConsumedChanges, revertConsumedChanges]); + + flushPersistRef.current = flushPersist; const abandonPendingChanges = useCallback(() => { previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; pendingUserEditsRefRef.current.current = {}; consumedPendingRef.current = null; + retryExplicitPatchRef.current = {}; }, []); useEffect(() => { - if (previousValuesRef.current === values) { + if (previousValuesRef.current === values || persistInFlightRef.current) { return undefined; } @@ -190,11 +238,12 @@ export function TemplateFormPersistProvider({ const persistContextValue = useMemo( () => ({ consumePendingChanges, + getRetryExplicitPatch, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges, }), - [consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges], + [consumePendingChanges, getRetryExplicitPatch, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges], ); return ( diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts index 2bd258237..c6a24bb53 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts @@ -10,7 +10,9 @@ export interface ITemplateFieldContextValue { } export interface ITemplatePersistContextValue { - consumePendingChanges(): Partial; + consumePendingChanges(explicitFields?: Partial): Partial; + /** Fields from a failed explicit submit (e.g. activation) to merge into the next retry. */ + getRetryExplicitPatch(): Partial; /** Clears a revert snapshot after an explicit `patchTemplate` succeeds. */ confirmConsumedChanges(): void; /** Restores the persist baseline and re-queues autosave when an explicit `patchTemplate` fails. */ diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts index ed10f4ec4..3b0c58971 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts @@ -15,17 +15,22 @@ export function useTemplateSaveRetry(): () => void { const dispatch = useDispatch(); const { consumePendingChanges, + getRetryExplicitPatch, confirmConsumedChanges, revertConsumedChanges, } = useTemplatePersist(); return useCallback(() => { const pendingChanges = consumePendingChanges(); + const changedFields = { + ...pendingChanges, + ...getRetryExplicitPatch(), + }; - if (Object.keys(pendingChanges).length > 0) { + if (Object.keys(changedFields).length > 0) { dispatch( patchTemplate({ - changedFields: pendingChanges, + changedFields, onSuccess: confirmConsumedChanges, onFailed: revertConsumedChanges, }), @@ -34,5 +39,5 @@ export function useTemplateSaveRetry(): () => void { } dispatch(saveTemplate()); - }, [consumePendingChanges, confirmConsumedChanges, dispatch, revertConsumedChanges]); + }, [consumePendingChanges, confirmConsumedChanges, dispatch, getRetryExplicitPatch, revertConsumedChanges]); } From 9ffaa62076c0c8a4dbe382d60264987699d0e81a Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 17:38:01 +0300 Subject: [PATCH 28/39] 59876 fix(template): harden autosave supersede, owners, and reference cleanup --- .../components/TemplateEdit/TemplateEdit.tsx | 1 - .../__tests__/useTemplateForm.test.tsx | 91 +++++++++++++++++++ .../TemplateEdit/useTemplateEditInit.ts | 8 +- .../TemplateFormPersistProvider.tsx | 34 +++++-- .../templateFormReferenceCleanup.ts | 30 +++++- .../__tests__/createTemplateEditTask.test.ts | 21 ++++- .../utils/createTemplateEditTask.ts | 3 +- 7 files changed, 170 insertions(+), 18 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 4d3e452ee..908217063 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -57,7 +57,6 @@ export function TemplateEdit({ aiTemplate, users, accessConditions, - isSubscribed, authUser, formik, openTask, diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 3646bfafb..6ec8882a3 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -439,6 +439,77 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).toHaveBeenCalledTimes(1); }); + it('flushes edits made during an in-flight autosave after the first patch succeeds', async () => { + const template = makeTemplate({ isActive: true, description: 'old', name: 'Original' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'first edit', false); + }); + + await flushPersist({ confirmSuccess: false }); + + act(() => { + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + calls[calls.length - 1][0].onSuccess(); + }); + + act(() => { + handle!.setFieldValue('name', 'second edit', false); + }); + + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(2); + expect((patchTemplate as unknown as jest.Mock).mock.calls[1][0].changedFields).toEqual({ + name: 'second edit', + }); + }); + + it('ignores stale autosave callbacks superseded by a newer patchTemplate dispatch', async () => { + const template = makeTemplate({ isActive: true, description: 'old', name: 'Original' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'first edit', false); + }); + await flushPersist({ confirmSuccess: false }); + + const firstOnSuccess = (patchTemplate as unknown as jest.Mock).mock.calls[0][0].onSuccess; + + act(() => { + handle!.setFieldValue('name', 'second edit', false); + }); + await flushPersist({ confirmSuccess: false }); + + expect(patchTemplate).toHaveBeenCalledTimes(2); + + act(() => { + firstOnSuccess(); + }); + + expect(handle!.values.name).toBe('second edit'); + + act(() => { + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + calls[calls.length - 1][0].onSuccess(); + }); + + act(() => { + handle!.setFieldValue('description', 'third edit', false); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(3); + expect((patchTemplate as unknown as jest.Mock).mock.calls[2][0].changedFields).toEqual({ + description: 'third edit', + }); + }); + it('does not dispatch patchTemplate on unmount after abandoning pending edits', async () => { const template = makeTemplate({ isActive: true, description: 'old' }); let handle: ISpyHandle | null = null; @@ -1143,6 +1214,26 @@ describe('useTemplateForm reference cleanup', () => { expect(handle!.values.tasks[0].rawPerformers).toHaveLength(0); }); + it('does not run reference cleanup when only a task name changes', () => { + const template = makeTemplate({ + tasks: [ + makeTask({ + name: 'Step with {{missing-field}}', + }), + ], + }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('tasks.0.name', 'Renamed {{missing-field}}', false); + }); + + expect(handle!.values.tasks[0].name).toBe('Renamed {{missing-field}}'); + expect(patchTemplate).not.toHaveBeenCalled(); + }); + it('cleans stale performer references synchronously when a task output field is removed', () => { const taskWithOutput = makeTask({ apiName: 'task-1', diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts index ec1905144..f59f3f4ab 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -20,7 +20,6 @@ type TUseTemplateEditInitParams = { aiTemplate: ITemplate | null; users: TUserListItem[]; accessConditions: boolean; - isSubscribed: boolean; authUser: IAuthUser; formik: FormikProps; openTask(taskUUID?: string): void; @@ -39,7 +38,6 @@ export function useTemplateEditInit({ aiTemplate, users, accessConditions, - isSubscribed, authUser, formik, openTask, @@ -68,14 +66,14 @@ export function useTemplateEditInit({ { check: checkSomeRouteIsActive(ERoutes.TemplatesCreateAI), init: () => { - const templateLocal = aiTemplate || createEmptyTemplate(authUser, users, accessConditions, isSubscribed); + const templateLocal = aiTemplate || createEmptyTemplate(authUser, users, accessConditions); setTemplate(templateLocal); saveTemplate(); }, }, { check: isCreateWorflowPage && !workflowTemplateId, - init: () => setTemplate(createEmptyTemplate(authUser, users, accessConditions, isSubscribed)), + init: () => setTemplate(createEmptyTemplate(authUser, users, accessConditions)), }, { check: isEditWorkflow, @@ -136,7 +134,7 @@ export function useTemplateEditInit({ // prop would reset Formik to that snapshot and discard any uncommitted // edits. Spreading from `formik.values` carries those edits into the // new Redux state so the reinitialize is a no-op for them. - const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, isSubscribed, users); + const newTemplateOwners = getNormalizedTemplateOwners(formik.values.owners, accessConditions, users); setTemplate({ ...formik.values, owners: newTemplateOwners }); } }, [location, users, template, formik.values]); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index c92570e3e..f20887d8e 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -21,6 +21,8 @@ type TConsumedPending = { isUserEdit: boolean; pendingUserEdits: Partial; explicitFields?: Partial; + /** Formik snapshot covered by the in-flight autosave patch. */ + dispatchedValues?: ITemplate; }; /** @@ -60,7 +62,8 @@ export function TemplateFormPersistProvider({ const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); const consumedPendingRef = useRef(null); const retryExplicitPatchRef = useRef>({}); - const persistInFlightRef = useRef(false); + const persistRequestIdRef = useRef(0); + const pendingDispatchRef = useRef<{ requestId: number; dispatchedValues: ITemplate } | null>(null); const flushPersistRef = useRef<() => void>(() => undefined); valuesRef.current = values; @@ -102,6 +105,7 @@ export function TemplateFormPersistProvider({ previousBaseline: previousValuesRef.current, isUserEdit, pendingUserEdits: { ...pendingUserEditsRefRef.current.current }, + ...(mode === 'flush' ? { dispatchedValues: valuesRef.current } : {}), }; } @@ -128,7 +132,7 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; retryExplicitPatchRef.current = {}; - previousValuesRef.current = valuesRef.current; + previousValuesRef.current = consumed?.dispatchedValues ?? valuesRef.current; if (Object.keys(pendingUserEditsRefRef.current.current).length === 0) { dirtyRefRef.current.current = false; @@ -186,23 +190,38 @@ export function TemplateFormPersistProvider({ }, [takePendingChanges]); const flushPersist = useCallback(() => { - if (persistInFlightRef.current) { + if (previousValuesRef.current === valuesRef.current) { + return; + } + + // Skip duplicate flushes for the same Formik snapshot while a patch is + // already queued. A newer edit changes `valuesRef`, which supersedes the + // in-flight patch via `takeLatest` and bumps `persistRequestIdRef`. + if (pendingDispatchRef.current?.dispatchedValues === valuesRef.current) { return; } const changedFields = takePendingChanges('flush'); if (Object.keys(changedFields).length > 0) { - persistInFlightRef.current = true; + const requestId = persistRequestIdRef.current + 1; + persistRequestIdRef.current = requestId; + pendingDispatchRef.current = { requestId, dispatchedValues: valuesRef.current }; dispatchRef.current( patchTemplate({ changedFields, onSuccess: () => { - persistInFlightRef.current = false; + if (requestId !== persistRequestIdRef.current) { + return; + } + pendingDispatchRef.current = null; confirmConsumedChanges(); }, onFailed: () => { - persistInFlightRef.current = false; + if (requestId !== persistRequestIdRef.current) { + return; + } + pendingDispatchRef.current = null; revertConsumedChanges(); }, }), @@ -218,10 +237,11 @@ export function TemplateFormPersistProvider({ pendingUserEditsRefRef.current.current = {}; consumedPendingRef.current = null; retryExplicitPatchRef.current = {}; + pendingDispatchRef.current = null; }, []); useEffect(() => { - if (previousValuesRef.current === values || persistInFlightRef.current) { + if (previousValuesRef.current === values) { return undefined; } diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts index 202b1c416..be05aa905 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts @@ -1,4 +1,4 @@ -import { IKickoff, ITemplate } from '../../../types/template'; +import { IKickoff, ITemplate, ITemplateTask } from '../../../types/template'; import { cleanTemplateReferences } from '../../../utils/template'; function getKickoffFieldApiNames(kickoff: IKickoff): string[] { @@ -8,6 +8,13 @@ function getKickoffFieldApiNames(kickoff: IKickoff): string[] { .sort(); } +function getTaskOutputFieldApiNames(task: ITemplateTask | undefined): string[] { + return (task?.fields || []) + .map((field) => field.apiName) + .filter(Boolean) + .sort(); +} + function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { const previousNames = getKickoffFieldApiNames(previous); const nextNames = getKickoffFieldApiNames(next); @@ -16,8 +23,16 @@ function didKickoffFieldsChange(previous: IKickoff, next: IKickoff): boolean { || previousNames.some((name, index) => name !== nextNames[index]); } +function didTaskOutputFieldsChange(previousTask: ITemplateTask | undefined, nextTask: ITemplateTask | undefined): boolean { + const previousNames = getTaskOutputFieldApiNames(previousTask); + const nextNames = getTaskOutputFieldApiNames(nextTask); + + return previousNames.length !== nextNames.length + || previousNames.some((name, index) => name !== nextNames[index]); +} + export function shouldRunReferenceCleanup(field: string, previous: ITemplate, next: ITemplate): boolean { - if (field === 'tasks' || field.startsWith('tasks.')) { + if (field === 'tasks') { return true; } @@ -25,6 +40,17 @@ export function shouldRunReferenceCleanup(field: string, previous: ITemplate, ne return didKickoffFieldsChange(previous.kickoff, next.kickoff); } + const taskFieldsMatch = /^tasks\.(\d+)\.fields$/.exec(field); + if (taskFieldsMatch) { + return true; + } + + const wholeTaskMatch = /^tasks\.(\d+)$/.exec(field); + if (wholeTaskMatch) { + const taskIndex = Number(wholeTaskMatch[1]); + return didTaskOutputFieldsChange(previous.tasks[taskIndex], next.tasks[taskIndex]); + } + return false; } diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts index 0bff4a14d..3d44e0485 100644 --- a/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts +++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts @@ -1,5 +1,7 @@ import { IAuthUser } from '../../../../types/redux'; -import { createNewTemplateTask } from '../createTemplateEditTask'; +import { TUserListItem } from '../../../../types/user'; +import { ETemplateOwnerRole } from '../../../../types/template'; +import { createEmptyTemplate, createNewTemplateTask } from '../createTemplateEditTask'; const authUser = { id: 1, @@ -15,3 +17,20 @@ describe('createNewTemplateTask', () => { expect(task.rawDueDate.sourceId).toBe('custom-task-api-name'); }); }); + +describe('createEmptyTemplate', () => { + const users = [ + { id: 1, firstName: 'Test', lastName: 'User' }, + { id: 2, firstName: 'Other', lastName: 'User' }, + ] as TUserListItem[]; + + it('keeps the creator as the sole owner on free plans with accessConditions enabled', () => { + const template = createEmptyTemplate(authUser, users, true); + + expect(template.owners).toHaveLength(1); + expect(template.owners[0]).toMatchObject({ + sourceId: '1', + role: ETemplateOwnerRole.Owner, + }); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts index fdfbda9ef..24529b491 100644 --- a/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts +++ b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts @@ -57,7 +57,6 @@ export function createEmptyTemplate( authUser: IAuthUser, users: TUserListItem[], accessConditions: boolean, - isSubscribed: boolean, ): ITemplate { return { description: '', @@ -90,7 +89,7 @@ export function createEmptyTemplate( role: ETemplateOwnerRole.Owner, }, ], - isSubscribed, + accessConditions, users, ), wfNameTemplate: '{{date}} — {{template-name}}', From 79d793f486696ab6641da0c0fee3752a3f46e386 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 17:58:30 +0300 Subject: [PATCH 29/39] 59876 fix(template): cancel stale autosave on discard and sync persist baseline --- .../TemplateControlls/TemplateControlls.tsx | 16 ++-- .../__tests__/useTemplateForm.test.tsx | 94 ++++++++++++++++++- .../TemplateFormPersistProvider.tsx | 26 +++-- .../useTemplateForm/useTemplateFormHook.ts | 1 + frontend/src/public/redux/template/actions.ts | 4 + .../public/redux/template/persistRequest.ts | 19 ++++ frontend/src/public/redux/template/saga.ts | 27 +++++- 7 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 frontend/src/public/redux/template/persistRequest.ts diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index b46cd2432..c62848f78 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -228,13 +228,15 @@ export function TemplateControlls({ diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 6ec8882a3..30fbc4e29 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -10,6 +10,7 @@ import { import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../TaskForm/Conditions/types'; import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist, useTemplateSaveRetry } from '../useTemplateForm'; import { patchTemplate, saveTemplate } from '../../../redux/actions'; +import { resetAutosavePersistRequestsForTests } from '../../../redux/template/persistRequest'; const mockDispatch = jest.fn(); @@ -142,6 +143,7 @@ function StatefulTemplateFormHarness({ describe('TemplateFormPersistProvider deactivation', () => { beforeEach(() => { (patchTemplate as unknown as jest.Mock).mockClear(); + resetAutosavePersistRequestsForTests(); }); it('flips isActive to false in Formik immediately when a non-activation field changes', async () => { @@ -529,7 +531,55 @@ describe('TemplateFormPersistProvider deactivation', () => { await flushPersist(); - expect(patchTemplate).not.toHaveBeenCalled(); + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({}); + }); + + it('ignores in-flight autosave callbacks after abandoning pending edits', async () => { + const template = makeTemplate({ isActive: true, description: 'old', name: 'Original' }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('description', 'discarded edit', false); + }); + await flushPersist({ confirmSuccess: false }); + + const abandonedOnSuccess = (patchTemplate as unknown as jest.Mock).mock.calls[0][0].onSuccess; + + act(() => { + handle!.abandonPendingChanges(); + }); + + expect(patchTemplate).toHaveBeenCalledTimes(2); + expect((patchTemplate as unknown as jest.Mock).mock.calls[1][0].changedFields).toEqual({}); + + act(() => { + handle!.setFieldValue('name', 'kept edit', false); + }); + await flushPersist({ confirmSuccess: false }); + + act(() => { + abandonedOnSuccess(); + }); + + expect(handle!.values.name).toBe('kept edit'); + + act(() => { + const calls = (patchTemplate as unknown as jest.Mock).mock.calls; + calls[calls.length - 1][0].onSuccess(); + }); + + act(() => { + handle!.setFieldValue('description', 'next edit', false); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(4); + expect((patchTemplate as unknown as jest.Mock).mock.calls[3][0].changedFields).toEqual({ + description: 'next edit', + }); }); it('dispatches patchTemplate on unmount when pending edits were not flushed', async () => { @@ -997,6 +1047,48 @@ describe('TemplateFormPersistProvider reinitialize', () => { }); }); + it('does not re-dispatch server-stamped fields on the next edit after autosave succeeds', async () => { + const template = makeTemplate({ isActive: true, description: 'old', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + const { rerender } = render( + { handle = h; }} + />, + ); + + act(() => { + handle!.setFieldValue('description', 'saved edit', false); + }); + await flushPersist(); + + rerender( + { handle = h; }} + />, + ); + await flushPersist(); + + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + handle!.setFieldValue('name', 'next edit', false); + }); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + name: 'next edit', + }); + }); + it('preserves nested task edits and sends valid changedFields after Redux reinitializes', async () => { const task = makeTask({ name: 'Original Task' }); const template = makeTemplate({ isActive: true, tasks: [task], dateUpdated: null }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index f20887d8e..0a162f8f2 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -5,6 +5,11 @@ import { useDispatch } from 'react-redux'; import { ITemplate } from '../../../types/template'; import { patchTemplate } from '../../../redux/actions'; +import { + abandonAutosavePersistRequests, + allocateAutosavePersistRequestId, + isAutosavePersistRequestCurrent, +} from '../../../redux/template/persistRequest'; import { TemplatePersistContext } from './contexts'; import { getChangedFields, getUnconsumedPendingEdits } from './templateFormUtils'; import { ITemplatePersistContextValue } from './types'; @@ -54,6 +59,7 @@ export function TemplateFormPersistProvider({ }: ITemplateFormPersistProviderProps) { const { values } = useFormikContext(); const dispatch = useDispatch(); + const latestReduxBaselineRef = useRef(null); const previousValuesRef = useRef(values); const valuesRef = useRef(values); const dispatchRef = useRef(dispatch); @@ -62,7 +68,6 @@ export function TemplateFormPersistProvider({ const persistBaselineSyncRefRef = useRef(persistBaselineSyncRef); const consumedPendingRef = useRef(null); const retryExplicitPatchRef = useRef>({}); - const persistRequestIdRef = useRef(0); const pendingDispatchRef = useRef<{ requestId: number; dispatchedValues: ITemplate } | null>(null); const flushPersistRef = useRef<() => void>(() => undefined); @@ -84,6 +89,7 @@ export function TemplateFormPersistProvider({ // Baseline must mirror the latest Redux snapshot for every field so // server-stamped or normalized values (dateUpdated, owners, ...) are not // diffed as user edits on the next flush. + latestReduxBaselineRef.current = reduxTemplate; previousValuesRef.current = { ...reduxTemplate }; }; @@ -132,7 +138,12 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; retryExplicitPatchRef.current = {}; - previousValuesRef.current = consumed?.dispatchedValues ?? valuesRef.current; + // Prefer the latest Redux snapshot from `persistBaselineSyncRef` (server- + // stamped fields). Until Redux reinitializes Formik after save, fall back + // to the in-flight Formik snapshot so post-save edits diff correctly. + previousValuesRef.current = latestReduxBaselineRef.current + ? { ...latestReduxBaselineRef.current } + : (consumed?.dispatchedValues ?? valuesRef.current); if (Object.keys(pendingUserEditsRefRef.current.current).length === 0) { dirtyRefRef.current.current = false; @@ -204,21 +215,21 @@ export function TemplateFormPersistProvider({ const changedFields = takePendingChanges('flush'); if (Object.keys(changedFields).length > 0) { - const requestId = persistRequestIdRef.current + 1; - persistRequestIdRef.current = requestId; + const requestId = allocateAutosavePersistRequestId(); pendingDispatchRef.current = { requestId, dispatchedValues: valuesRef.current }; dispatchRef.current( patchTemplate({ changedFields, + requestId, onSuccess: () => { - if (requestId !== persistRequestIdRef.current) { + if (!isAutosavePersistRequestCurrent(requestId)) { return; } pendingDispatchRef.current = null; confirmConsumedChanges(); }, onFailed: () => { - if (requestId !== persistRequestIdRef.current) { + if (!isAutosavePersistRequestCurrent(requestId)) { return; } pendingDispatchRef.current = null; @@ -232,6 +243,9 @@ export function TemplateFormPersistProvider({ flushPersistRef.current = flushPersist; const abandonPendingChanges = useCallback(() => { + abandonAutosavePersistRequests(); + // Cancel a debounced autosave saga (`takeLatest`) without enqueueing a save. + dispatchRef.current(patchTemplate({ changedFields: {} })); previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; pendingUserEditsRefRef.current.current = {}; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts index 9399c6cf0..a2fda22f4 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts @@ -69,6 +69,7 @@ export function useTemplateForm( pendingUserEditsRef.current = {}; dirtyRef.current = false; formik.resetForm({ values: initialValues }); + persistBaselineSyncRef.current?.(initialValues); } lastSyncedInitialValuesRef.current = initialValues; diff --git a/frontend/src/public/redux/template/actions.ts b/frontend/src/public/redux/template/actions.ts index 4c537e5ea..05a187772 100644 --- a/frontend/src/public/redux/template/actions.ts +++ b/frontend/src/public/redux/template/actions.ts @@ -55,6 +55,8 @@ export type TSaveTemplatePayload = | { onSuccess?(): void; onFailed?(): void; + /** Autosave generation from `TemplateFormPersistProvider`; stale saves are skipped after discard. */ + requestId?: number; } | undefined; export type TSaveTemplate = ITypedReduxAction; @@ -97,6 +99,8 @@ export type TPatchTemplatePayload = { changedFields: Partial; onSuccess?(): void; onFailed?(): void; + /** Autosave generation from `TemplateFormPersistProvider`; stale saves are skipped after discard. */ + requestId?: number; }; export type TPatchTemplate = ITypedReduxAction; export const patchTemplate: (payload: TPatchTemplatePayload) => TPatchTemplate = actionGenerator< diff --git a/frontend/src/public/redux/template/persistRequest.ts b/frontend/src/public/redux/template/persistRequest.ts new file mode 100644 index 000000000..9becfc48b --- /dev/null +++ b/frontend/src/public/redux/template/persistRequest.ts @@ -0,0 +1,19 @@ +let autosavePersistRequestId = 0; + +export function allocateAutosavePersistRequestId(): number { + autosavePersistRequestId += 1; + return autosavePersistRequestId; +} + +export function abandonAutosavePersistRequests(): number { + autosavePersistRequestId += 1; + return autosavePersistRequestId; +} + +export function isAutosavePersistRequestCurrent(requestId: number | undefined): boolean { + return requestId === undefined || requestId === autosavePersistRequestId; +} + +export function resetAutosavePersistRequestsForTests(): void { + autosavePersistRequestId = 0; +} diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index 1d995b762..6bbb15842 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -42,6 +42,7 @@ import { TSaveTemplate, TStopAITemplateGeneration, } from './actions'; +import { isAutosavePersistRequestCurrent } from './persistRequest'; import { getIsUserSubsribed, getSubscriptionPlan, getUsers } from '../selectors/user'; import { createTemplate } from '../../api/createTemplate'; @@ -97,7 +98,11 @@ function* fetchTemplate({ payload: id }: TLoadTemplate) { } } -function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }: TPatchTemplate) { +function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed, requestId } }: TPatchTemplate) { + if (Object.keys(changedFields).length === 0) { + return; + } + const template: ReturnType = yield select(getTemplateData); yield put(setTemplateStatus(ETemplateStatus.Saving)); @@ -128,7 +133,12 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }: yield put(setTemplate(newTemplate)); yield delay(350); - yield put(saveTemplate({ onSuccess, onFailed })); + + if (!isAutosavePersistRequestCurrent(requestId)) { + return; + } + + yield put(saveTemplate({ onSuccess, onFailed, requestId })); } function* patchTaskSaga({ payload: { taskUUID, changedFields } }: TPatchTask) { @@ -191,7 +201,7 @@ function* createOrUpdateTemplate(template: ITemplateRequest, isSubscribed: boole } } -function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) { +function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, requestId?: number) { const isTemplatePage = checkSomeRouteIsActive( ERoutes.TemplateView, ERoutes.TemplatesCreate, @@ -202,6 +212,10 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) { if (!isTemplatePage) return; + if (!isAutosavePersistRequestCurrent(requestId)) { + return; + } + const isSubscribed: ReturnType = yield select(getIsUserSubsribed); const users: ReturnType = yield select(getUsers); @@ -209,6 +223,11 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) { const templateRequest = mapTemplateRequest(editingTemplate); const savedTemplate: ITemplate | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users); + + if (!isAutosavePersistRequestCurrent(requestId)) { + return; + } + const lastTemplateState: ReturnType = yield select(getTemplateData); if (!savedTemplate) { @@ -366,7 +385,7 @@ export function* watchSaveTemplate() { ); while (true) { const { payload }: TSaveTemplate = yield take(autosaveChannel); - yield call(fetchSaveTemplate, payload?.onSuccess, payload?.onFailed); + yield call(fetchSaveTemplate, payload?.onSuccess, payload?.onFailed, payload?.requestId); } } From 2c41ba51608346a1a5407300d8964e74ea806441 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 18:28:09 +0300 Subject: [PATCH 30/39] 59876 fix(template): reset Saving status when patch saga is cancelled or skipped --- frontend/src/public/redux/template/saga.ts | 66 +++++++++++++--------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index 6bbb15842..ca30a5880 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -103,42 +103,50 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed, req return; } - const template: ReturnType = yield select(getTemplateData); + try { + const template: ReturnType = yield select(getTemplateData); - yield put(setTemplateStatus(ETemplateStatus.Saving)); + yield put(setTemplateStatus(ETemplateStatus.Saving)); - const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; - let shouldDeactivateTemplate = changedFields.isActive === true - ? false - : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); + const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; + let shouldDeactivateTemplate = changedFields.isActive === true + ? false + : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); - if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { - const kickoffChanged = changedFields.kickoff; - const previousKickoff = template.kickoff; + if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { + const kickoffChanged = changedFields.kickoff; + const previousKickoff = template.kickoff; - if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { - shouldDeactivateTemplate = - kickoffChanged?.description === previousKickoff.description ? shouldDeactivateTemplate : false; + if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { + shouldDeactivateTemplate = + kickoffChanged?.description === previousKickoff.description ? shouldDeactivateTemplate : false; + } } - } - const mergedTemplate: ITemplate = { - ...template, - ...changedFields, - ...(shouldDeactivateTemplate && { isActive: false }), - }; + const mergedTemplate: ITemplate = { + ...template, + ...changedFields, + ...(shouldDeactivateTemplate && { isActive: false }), + }; - const needsCleanup = changedFields.hasOwnProperty('tasks') || changedFields.hasOwnProperty('kickoff'); - const newTemplate = needsCleanup ? cleanTemplateReferences(mergedTemplate) : mergedTemplate; + const needsCleanup = changedFields.hasOwnProperty('tasks') || changedFields.hasOwnProperty('kickoff'); + const newTemplate = needsCleanup ? cleanTemplateReferences(mergedTemplate) : mergedTemplate; - yield put(setTemplate(newTemplate)); - yield delay(350); + yield put(setTemplate(newTemplate)); + yield delay(350); - if (!isAutosavePersistRequestCurrent(requestId)) { - return; - } + if (!isAutosavePersistRequestCurrent(requestId)) { + yield put(setTemplateStatus(ETemplateStatus.Saved)); + return; + } - yield put(saveTemplate({ onSuccess, onFailed, requestId })); + yield put(saveTemplate({ onSuccess, onFailed, requestId })); + } finally { + if (yield cancelled()) { + yield put(setTemplateStatus(ETemplateStatus.Saved)); + onFailed?.(); + } + } } function* patchTaskSaga({ payload: { taskUUID, changedFields } }: TPatchTask) { @@ -213,6 +221,9 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque if (!isTemplatePage) return; if (!isAutosavePersistRequestCurrent(requestId)) { + if (requestId !== undefined) { + yield put(setTemplateStatus(ETemplateStatus.Saved)); + } return; } @@ -225,6 +236,9 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque const savedTemplate: ITemplate | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users); if (!isAutosavePersistRequestCurrent(requestId)) { + if (requestId !== undefined) { + yield put(setTemplateStatus(ETemplateStatus.Saved)); + } return; } From c582cd0b947c189a3b6539dc679abe30b45e0837 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 18:54:05 +0300 Subject: [PATCH 31/39] 59876 fix(template): keep Saving status until the latest autosave completes --- .../useTemplateForm/TemplateFormPersistProvider.tsx | 4 +++- frontend/src/public/redux/template/saga.ts | 8 -------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index 0a162f8f2..19143ce63 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -4,7 +4,8 @@ import { useFormikContext } from 'formik'; import { useDispatch } from 'react-redux'; import { ITemplate } from '../../../types/template'; -import { patchTemplate } from '../../../redux/actions'; +import { patchTemplate, setTemplateStatus } from '../../../redux/actions'; +import { ETemplateStatus } from '../../../types/redux'; import { abandonAutosavePersistRequests, allocateAutosavePersistRequestId, @@ -245,6 +246,7 @@ export function TemplateFormPersistProvider({ const abandonPendingChanges = useCallback(() => { abandonAutosavePersistRequests(); // Cancel a debounced autosave saga (`takeLatest`) without enqueueing a save. + dispatchRef.current(setTemplateStatus(ETemplateStatus.Saved)); dispatchRef.current(patchTemplate({ changedFields: {} })); previousValuesRef.current = valuesRef.current; dirtyRefRef.current.current = false; diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index ca30a5880..a3a53c5c8 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -136,14 +136,12 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed, req yield delay(350); if (!isAutosavePersistRequestCurrent(requestId)) { - yield put(setTemplateStatus(ETemplateStatus.Saved)); return; } yield put(saveTemplate({ onSuccess, onFailed, requestId })); } finally { if (yield cancelled()) { - yield put(setTemplateStatus(ETemplateStatus.Saved)); onFailed?.(); } } @@ -221,9 +219,6 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque if (!isTemplatePage) return; if (!isAutosavePersistRequestCurrent(requestId)) { - if (requestId !== undefined) { - yield put(setTemplateStatus(ETemplateStatus.Saved)); - } return; } @@ -236,9 +231,6 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque const savedTemplate: ITemplate | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users); if (!isAutosavePersistRequestCurrent(requestId)) { - if (requestId !== undefined) { - yield put(setTemplateStatus(ETemplateStatus.Saved)); - } return; } From 6edbc46d1b554812a900cd41a6dfebb0d9832cc0 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 19:05:52 +0300 Subject: [PATCH 32/39] 59876 fix(template): preserve create id from superseded autosaves --- frontend/src/public/redux/template/saga.ts | 105 ++++++++++++--------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts index a3a53c5c8..85c670d44 100644 --- a/frontend/src/public/redux/template/saga.ts +++ b/frontend/src/public/redux/template/saga.ts @@ -67,6 +67,35 @@ import { setGeneralLoaderVisibility } from '../general/actions'; import { generateAITemplate } from '../../api/generateAITemplate'; import { discardTemplateChanges } from '../../api/discardTemplateChanges'; +function applySavedTemplateIds(lastTemplateState: ITemplate, savedTemplate: ITemplate): ITemplate { + const savedTasksMap = new Map(savedTemplate.tasks.map((task) => [task.apiName, task])); + + return { + ...insertId(lastTemplateState, savedTemplate), + publicUrl: savedTemplate.publicUrl, + embedUrl: savedTemplate.embedUrl, + tasks: lastTemplateState.tasks.map((task) => ({ + ...task, + ancestors: savedTasksMap.get(task.apiName)?.ancestors || [], + })), + }; +} + +function* mergeSupersededCreateResponse(savedTemplate: ITemplate, wasCreate: boolean) { + if (!wasCreate || !savedTemplate.id) { + return; + } + + const lastTemplateState: ReturnType = yield select(getTemplateData); + + if (lastTemplateState.id) { + return; + } + + yield put(setTemplate(applySavedTemplateIds(lastTemplateState, savedTemplate))); + history.replace(ERoutes.TemplatesEdit.replace(':id', String(savedTemplate.id))); +} + function* setTemplateByTemplateResponse(template: ITemplateResponse) { const isSubscribed: ReturnType = yield select(getIsUserSubsribed); const billingPlan: ReturnType = yield select(getSubscriptionPlan); @@ -103,48 +132,42 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed, req return; } - try { - const template: ReturnType = yield select(getTemplateData); + const template: ReturnType = yield select(getTemplateData); - yield put(setTemplateStatus(ETemplateStatus.Saving)); + yield put(setTemplateStatus(ETemplateStatus.Saving)); - const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; - let shouldDeactivateTemplate = changedFields.isActive === true - ? false - : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); + const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl']; + let shouldDeactivateTemplate = changedFields.isActive === true + ? false + : Object.keys(changedFields).some((key) => !nonDeactivativeFields.includes(key as keyof ITemplate)); - if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { - const kickoffChanged = changedFields.kickoff; - const previousKickoff = template.kickoff; + if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) { + const kickoffChanged = changedFields.kickoff; + const previousKickoff = template.kickoff; - if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { - shouldDeactivateTemplate = - kickoffChanged?.description === previousKickoff.description ? shouldDeactivateTemplate : false; - } + if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { + shouldDeactivateTemplate = + kickoffChanged?.description === previousKickoff.description ? shouldDeactivateTemplate : false; } + } - const mergedTemplate: ITemplate = { - ...template, - ...changedFields, - ...(shouldDeactivateTemplate && { isActive: false }), - }; - - const needsCleanup = changedFields.hasOwnProperty('tasks') || changedFields.hasOwnProperty('kickoff'); - const newTemplate = needsCleanup ? cleanTemplateReferences(mergedTemplate) : mergedTemplate; + const mergedTemplate: ITemplate = { + ...template, + ...changedFields, + ...(shouldDeactivateTemplate && { isActive: false }), + }; - yield put(setTemplate(newTemplate)); - yield delay(350); + const needsCleanup = changedFields.hasOwnProperty('tasks') || changedFields.hasOwnProperty('kickoff'); + const newTemplate = needsCleanup ? cleanTemplateReferences(mergedTemplate) : mergedTemplate; - if (!isAutosavePersistRequestCurrent(requestId)) { - return; - } + yield put(setTemplate(newTemplate)); + yield delay(350); - yield put(saveTemplate({ onSuccess, onFailed, requestId })); - } finally { - if (yield cancelled()) { - onFailed?.(); - } + if (!isAutosavePersistRequestCurrent(requestId)) { + return; } + + yield put(saveTemplate({ onSuccess, onFailed, requestId })); } function* patchTaskSaga({ payload: { taskUUID, changedFields } }: TPatchTask) { @@ -228,9 +251,14 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque const editingTemplate: ReturnType = yield select(getTemplateData); const templateRequest = mapTemplateRequest(editingTemplate); + const isTemplateCreated = !templateRequest.id; const savedTemplate: ITemplate | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users); if (!isAutosavePersistRequestCurrent(requestId)) { + if (savedTemplate) { + yield mergeSupersededCreateResponse(savedTemplate, isTemplateCreated); + } + return; } @@ -244,22 +272,11 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void, reque return; } - const isTemplateCreated = !templateRequest.id; - const newTemplateState: ITemplate = { - ...insertId(lastTemplateState, savedTemplate), + ...applySavedTemplateIds(lastTemplateState, savedTemplate), updatedBy: savedTemplate.updatedBy, dateUpdated: savedTemplate.dateUpdated, - publicUrl: savedTemplate.publicUrl, - embedUrl: savedTemplate.embedUrl, owners: savedTemplate.owners ?? lastTemplateState.owners, - tasks: (() => { - const savedTasksMap = new Map(savedTemplate.tasks.map((task) => [task.apiName, task])); - return lastTemplateState.tasks.map((task) => ({ - ...task, - ancestors: savedTasksMap.get(task.apiName)?.ancestors || [], - })); - })(), }; yield put(setTemplate(newTemplateState)); From 423e48cf4284f2f4f1856f1ce9322760fe942289 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 19:25:26 +0300 Subject: [PATCH 33/39] 59876 fix(template): keep create-session edits and sync Formik on activation failure --- .../components/TemplateEdit/TemplateEdit.tsx | 11 ++-- .../__tests__/useTemplateForm.test.tsx | 56 +++++++++++++++++++ .../TemplateFormPersistProvider.tsx | 22 +++++--- .../useTemplateForm/templateFormUtils.ts | 5 +- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 908217063..292998d1d 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { useSelector } from 'react-redux'; import { useIntl } from 'react-intl'; @@ -10,7 +10,7 @@ import { ESubscriptionPlan } from '../../types/account'; import { ETemplateStatus } from '../../types/redux'; import { TemplateForm, useTemplateForm } from './useTemplateForm'; -import { ITemplateEditParams, TTemplateEditProps } from './templateEditPage.types'; +import { TTemplateEditProps } from './templateEditPage.types'; export * from './templateEditPage.types'; @@ -31,10 +31,11 @@ export function TemplateEdit({ loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const templateIdentityKey = (match.params as ITemplateEditParams).id ?? 'create'; + const createSessionKeyRef = useRef('create'); + const templateFormKey = template.id ?? createSessionKeyRef.current; const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm( template, - templateIdentityKey, + template.id, ); const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; @@ -74,7 +75,7 @@ export function TemplateEdit({ return ( { }); }); + it('reverts explicit activation fields in Formik after a failed submit', async () => { + const template = makeTemplate({ isActive: false, name: 'Original', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('name', 'Unsaved edit', false); + handle!.setFieldValue('isActive', true, false); + }); + + act(() => { + handle!.consumePendingChanges({ isActive: true }); + handle!.revertConsumedChanges(); + }); + + expect(handle!.values.name).toBe('Unsaved edit'); + expect(handle!.values.isActive).toBe(false); + await flushPersist(); + + expect(patchTemplate).toHaveBeenCalledTimes(1); + expect((patchTemplate as unknown as jest.Mock).mock.calls[0][0].changedFields).toEqual({ + name: 'Unsaved edit', + }); + }); + it('re-queues autosave after a failed explicit submit when Redux reinitializes during the in-flight patch', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; @@ -674,6 +700,36 @@ describe('useTemplateForm reinitialize', () => { expect(hookResult!.dirtyRef.current).toBe(false); }); + it('preserves pending edits when a new template receives its first id after create autosave', () => { + const newTemplate = makeTemplate({ id: undefined, name: 'New', dateUpdated: null }); + const savedTemplate = { ...newTemplate, id: 42 }; + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('name', 'Edited during create save', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.name).toBe('Edited during create save'); + expect(hookResult!.pendingUserEditsRef.current.name).toBe('Edited during create save'); + expect(hookResult!.dirtyRef.current).toBe(true); + }); + it('merges pending user edits into resolved Formik initialValues', () => { const template = makeTemplate({ name: 'Original', dateUpdated: null }); let hookResult: ReturnType | null = null; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index 19143ce63..f994ceacd 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -11,7 +11,7 @@ import { allocateAutosavePersistRequestId, isAutosavePersistRequestCurrent, } from '../../../redux/template/persistRequest'; -import { TemplatePersistContext } from './contexts'; +import { TemplatePersistContext, useTemplateField } from './contexts'; import { getChangedFields, getUnconsumedPendingEdits } from './templateFormUtils'; import { ITemplatePersistContextValue } from './types'; @@ -59,6 +59,7 @@ export function TemplateFormPersistProvider({ children, }: ITemplateFormPersistProviderProps) { const { values } = useFormikContext(); + const { setValues } = useTemplateField(); const dispatch = useDispatch(); const latestReduxBaselineRef = useRef(null); const previousValuesRef = useRef(values); @@ -71,12 +72,14 @@ export function TemplateFormPersistProvider({ const retryExplicitPatchRef = useRef>({}); const pendingDispatchRef = useRef<{ requestId: number; dispatchedValues: ITemplate } | null>(null); const flushPersistRef = useRef<() => void>(() => undefined); + const setValuesRef = useRef(setValues); valuesRef.current = values; dispatchRef.current = dispatch; dirtyRefRef.current = dirtyRef; pendingUserEditsRefRef.current = pendingUserEditsRef; persistBaselineSyncRefRef.current = persistBaselineSyncRef; + setValuesRef.current = setValues; useEffect(() => { persistBaselineSyncRefRef.current.current = (reduxTemplate) => { @@ -172,13 +175,18 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; - // Formik `values` are unchanged, so the `[values]` effect will not re-run. - // Re-queue autosave directly against the restored baseline instead of - // relying on `setValues`, which would diff against a Redux snapshot that may - // already include the consumed (but unsaved) fields from the failed patch. - if (consumed.isUserEdit && previousValuesRef.current !== valuesRef.current) { - flushPersistRef.current(); + let revertedValues: ITemplate = { ...valuesRef.current }; + + if (consumed.explicitFields) { + (Object.keys(consumed.explicitFields) as (keyof ITemplate)[]).forEach((key) => { + revertedValues = { + ...revertedValues, + [key]: consumed.previousBaseline[key], + }; + }); } + + setValuesRef.current(revertedValues); }, []); const consumePendingChanges = useCallback((explicitFields?: Partial) => { diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts index 535dc8f91..7fefe6f42 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts @@ -155,7 +155,10 @@ export function hasTemplateIdentityChanged( } // First id assignment after create — same template session. - if (previousIdentity === undefined && typeof nextIdentity === 'number') { + const isCreateSessionIdentity = (identity: string | number | undefined) => + identity === undefined || identity === 'create'; + + if (isCreateSessionIdentity(previousIdentity) && typeof nextIdentity === 'number') { return false; } From db682cb56858214ef458eec17cb39c539540b397 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 19:39:06 +0300 Subject: [PATCH 34/39] 59876 fix(template): isolate create flows and re-queue autosave after failed activation --- .../components/TemplateEdit/TemplateEdit.tsx | 13 +++- .../__tests__/useTemplateForm.test.tsx | 61 ++++++++++++++++++- .../TemplateFormPersistProvider.tsx | 20 +++++- .../useTemplateForm/templateFormUtils.ts | 21 ++++++- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx index 292998d1d..4dc1e9edf 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -9,6 +9,7 @@ import { getSubscriptionPlan } from '../../redux/selectors/user'; import { ESubscriptionPlan } from '../../types/account'; import { ETemplateStatus } from '../../types/redux'; import { TemplateForm, useTemplateForm } from './useTemplateForm'; +import { resolveTemplateFormIdentity } from './useTemplateForm/templateFormUtils'; import { TTemplateEditProps } from './templateEditPage.types'; @@ -31,11 +32,17 @@ export function TemplateEdit({ loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const createSessionKeyRef = useRef('create'); - const templateFormKey = template.id ?? createSessionKeyRef.current; + const templateIdentity = resolveTemplateFormIdentity(template, location); + const createSessionKeyRef = useRef(undefined); + + if (!template.id && typeof templateIdentity === 'string') { + createSessionKeyRef.current = templateIdentity; + } + + const templateFormKey = template.id ?? createSessionKeyRef.current ?? 'create'; const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm( template, - template.id, + templateIdentity, ); const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index 4d5b5c43a..e7093e170 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -326,6 +326,36 @@ describe('TemplateFormPersistProvider deactivation', () => { }); }); + it('re-queues autosave on failed activation when isActive was not flipped in Formik', () => { + const template = makeTemplate({ isActive: false, name: 'Original', dateUpdated: null }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('name', 'Unsaved edit', false); + }); + + act(() => { + handle!.consumePendingChanges({ isActive: true }); + }); + + const callsBeforeRevert = (patchTemplate as unknown as jest.Mock).mock.calls.length; + + act(() => { + handle!.revertConsumedChanges(); + }); + + const callsAfterRevert = (patchTemplate as unknown as jest.Mock).mock.calls.slice(callsBeforeRevert); + + expect(handle!.values.name).toBe('Unsaved edit'); + expect(handle!.values.isActive).toBe(false); + expect(callsAfterRevert).toHaveLength(1); + expect(callsAfterRevert[0][0].changedFields).toEqual({ + name: 'Unsaved edit', + }); + }); + it('re-queues autosave after a failed explicit submit when Redux reinitializes during the in-flight patch', async () => { const template = makeTemplate({ isActive: true, owners: [] }); let handle: ISpyHandle | null = null; @@ -708,7 +738,7 @@ describe('useTemplateForm reinitialize', () => { const { rerender } = render( { hookResult = result; }} />, ); @@ -730,6 +760,35 @@ describe('useTemplateForm reinitialize', () => { expect(hookResult!.dirtyRef.current).toBe(true); }); + it('drops pending edits when switching between create flows', () => { + const newTemplate = makeTemplate({ id: undefined, name: 'Draft', dateUpdated: null }); + let hookResult: ReturnType | null = null; + + const { rerender } = render( + { hookResult = result; }} + />, + ); + + act(() => { + hookResult!.setFieldValue('name', 'Manual draft', false); + }); + + rerender( + { hookResult = result; }} + />, + ); + + expect(hookResult!.formik.values.name).toBe('AI Template'); + expect(hookResult!.pendingUserEditsRef.current).toEqual({}); + expect(hookResult!.dirtyRef.current).toBe(false); + }); + it('merges pending user edits into resolved Formik initialValues', () => { const template = makeTemplate({ name: 'Original', dateUpdated: null }); let hookResult: ReturnType | null = null; diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index f994ceacd..58cad1eb3 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -175,18 +175,34 @@ export function TemplateFormPersistProvider({ consumedPendingRef.current = null; - let revertedValues: ITemplate = { ...valuesRef.current }; + let valuesChangedByExplicitRevert = false; if (consumed.explicitFields) { + let revertedValues: ITemplate = { ...valuesRef.current }; + (Object.keys(consumed.explicitFields) as (keyof ITemplate)[]).forEach((key) => { + if (valuesRef.current[key] !== consumed.previousBaseline[key]) { + valuesChangedByExplicitRevert = true; + } revertedValues = { ...revertedValues, [key]: consumed.previousBaseline[key], }; }); + + if (valuesChangedByExplicitRevert) { + setValuesRef.current(revertedValues); + } } - setValuesRef.current(revertedValues); + // When explicit fields (e.g. isActive) were reverted in Formik, the values + // effect will re-queue autosave. Otherwise consumed user edits are still + // visible but no longer match the restored baseline — flush now so they + // are not stranded without autosave (e.g. failed activation in + // TemplateControlls never flips isActive in Formik before the patch). + if (previousValuesRef.current !== valuesRef.current && !valuesChangedByExplicitRevert) { + flushPersistRef.current(); + } }, []); const consumePendingChanges = useCallback((explicitFields?: Partial) => { diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts index 7fefe6f42..c03bbe882 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts @@ -1,4 +1,21 @@ import { ITemplate, ITemplateTask } from '../../../types/template'; +import { getTemplateIdFromUrl } from '../../../utils/template'; + +export function resolveTemplateFormIdentity( + template: ITemplate, + location: { pathname: string; search: string }, +): string | number | undefined { + if (template.id) { + return template.id; + } + + const systemTemplateId = getTemplateIdFromUrl(location.search); + if (systemTemplateId) { + return `create:system:${systemTemplateId}`; + } + + return `create:${location.pathname}`; +} export function setNestedFieldValue(obj: ITemplate, path: string, value: unknown): ITemplate { const taskPathMatch = path.match(/^tasks\.(\d+)(?:\.(.+))?$/); @@ -156,7 +173,9 @@ export function hasTemplateIdentityChanged( // First id assignment after create — same template session. const isCreateSessionIdentity = (identity: string | number | undefined) => - identity === undefined || identity === 'create'; + identity === undefined + || identity === 'create' + || (typeof identity === 'string' && identity.startsWith('create:')); if (isCreateSessionIdentity(previousIdentity) && typeof nextIdentity === 'number') { return false; From 0b7b49ec79ca62abea2e296a66e00f2bcd4e5f23 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 19:46:13 +0300 Subject: [PATCH 35/39] 59876 fix(template): mark Saving as soon as Formik has uncommitted edits --- .../__tests__/useTemplateForm.test.tsx | 23 +++++++++++++++++-- .../TemplateFormPersistProvider.tsx | 7 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx index e7093e170..22f2b6b81 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -9,7 +9,8 @@ import { } from '../../../types/template'; import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../TaskForm/Conditions/types'; import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist, useTemplateSaveRetry } from '../useTemplateForm'; -import { patchTemplate, saveTemplate } from '../../../redux/actions'; +import { patchTemplate, saveTemplate, setTemplateStatus } from '../../../redux/actions'; +import { ETemplateStatus } from '../../../types/redux'; import { resetAutosavePersistRequestsForTests } from '../../../redux/template/persistRequest'; const mockDispatch = jest.fn(); @@ -178,6 +179,24 @@ describe('TemplateFormPersistProvider deactivation', () => { expect(patchTemplate).toHaveBeenCalledTimes(1); }); + it('marks template as Saving before the deferred autosave flush', () => { + const template = makeTemplate({ isActive: true, isPublic: false }); + let handle: ISpyHandle | null = null; + + render( { handle = h; }} />); + + mockDispatch.mockClear(); + (patchTemplate as unknown as jest.Mock).mockClear(); + + act(() => { + handle!.setFieldValue('isPublic', true, false); + }); + + expect(handle!.values.isActive).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith(setTemplateStatus(ETemplateStatus.Saving)); + expect(patchTemplate).not.toHaveBeenCalled(); + }); + it('does not deactivate when only kickoff.description changes (matches saga special case)', async () => { const template = makeTemplate({ isActive: true, @@ -1533,7 +1552,7 @@ describe('useTemplateSaveRetry', () => { }); expect(saveTemplate).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(setTemplateStatus(ETemplateStatus.Saving)); expect(patchTemplate).toHaveBeenCalledWith({ changedFields: { name: 'Retry edit', isActive: false }, onSuccess: expect.any(Function), diff --git a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx index 58cad1eb3..c4c255b85 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -285,6 +285,13 @@ export function TemplateFormPersistProvider({ return undefined; } + if ( + dirtyRefRef.current.current + && Object.keys(getChangedFields(previousValuesRef.current, values)).length > 0 + ) { + dispatchRef.current(setTemplateStatus(ETemplateStatus.Saving)); + } + const timeoutId = window.setTimeout(() => { flushPersist(); }, 0); From db4b80fe95e5242022b2ea8d817e02d7f2df6d01 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 19:55:29 +0300 Subject: [PATCH 36/39] 59876 fix(template): read live Formik tasks for create open and delay edits --- .../__tests__/useTemplateEditTasks.test.ts | 87 +++++++++++++++++++ .../TemplateEdit/useTemplateEditInit.ts | 4 +- .../TemplateEdit/useTemplateEditTasks.ts | 4 +- 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts new file mode 100644 index 000000000..b047bba1b --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts @@ -0,0 +1,87 @@ +/// +import * as React from 'react'; +import { act, render } from '@testing-library/react'; +import { FormikProps } from 'formik'; + +import { ITemplate, ITemplateTask } from '../../../types/template'; +import { useTemplateEditTasks } from '../useTemplateEditTasks'; +import { intlMock } from '../../../__stubs__/intlMock'; + +const makeTask = (overrides: Partial = {}): ITemplateTask => ({ + apiName: 'task-1', + number: 1, + name: 'Step 1', + description: '', + delay: null, + rawDueDate: null as any, + requireCompletionByAll: false, + skipForStarter: false, + rawPerformers: [], + fields: [], + uuid: 'uuid-1', + conditions: [], + checklists: [], + revertTask: null, + ancestors: [], + ...overrides, +}); + +const makeTemplate = (tasks: ITemplateTask[]): ITemplate => + ({ + id: 1, + name: 'Template', + description: '', + isActive: true, + finalizable: false, + completionNotification: false, + reminderNotification: false, + dateUpdated: null, + updatedBy: null, + owners: [], + kickoff: { description: '', fields: [] } as any, + tasks, + isPublic: false, + publicUrl: null, + publicSuccessUrl: null, + isEmbedded: false, + embedUrl: null, + wfNameTemplate: null, + tasksCount: tasks.length, + performersCount: 0, + }) as ITemplate; + +describe('useTemplateEditTasks', () => { + it('merges delay edits onto the current Formik task snapshot', () => { + const staleTask = makeTask({ name: 'Old name', delay: null }); + const liveTask = makeTask({ name: 'Edited in form', delay: null }); + const setFieldValue = jest.fn(); + let editDelay: ((delay: string) => void) | null = null; + + function Harness() { + const { getTaskListItem } = useTemplateEditTasks({ + authUser: { account: { id: 1 } } as any, + formik: { values: makeTemplate([liveTask]) } as FormikProps, + setFieldValue, + users: [], + accessConditions: true, + formatMessage: intlMock.formatMessage, + isSubscribed: true, + }); + + editDelay = getTaskListItem(staleTask, 0, [liveTask]).editDelay; + return null; + } + + render(React.createElement(Harness)); + + act(() => { + editDelay!('2d'); + }); + + expect(setFieldValue).toHaveBeenCalledWith( + 'tasks', + [expect.objectContaining({ uuid: 'uuid-1', name: 'Edited in form', delay: '2d' })], + false, + ); + }); +}); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts index f59f3f4ab..11c2d7215 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -97,9 +97,9 @@ export function useTemplateEditInit({ useEffect(() => { if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { - openTask(template.tasks[0]?.uuid); + openTask(formik.values.tasks[0]?.uuid); } - }, [template.tasks, location.pathname]); + }, [formik.values.tasks, location.pathname, openTask]); useEffect(() => { // Derive variables from Formik, not the Redux `template` prop. Kickoff/task diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts index 435e3f2be..e4be8ccf8 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts @@ -126,7 +126,7 @@ export function useTemplateEditTasks({ const handleEditTaskField = (targetTask: ITemplateTask) => (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => { changeTasks( - tasks.map((task) => (targetTask.uuid === task.uuid ? { ...targetTask, [field]: value } : task)), + tasks.map((task) => (targetTask.uuid === task.uuid ? { ...task, [field]: value } : task)), ); }; @@ -152,7 +152,7 @@ export function useTemplateEditTasks({ }; const editDelay = (targetTask: ITemplateTask) => (delay: string) => { - changeTasks(tasks.map((task) => (task.uuid === targetTask.uuid ? { ...targetTask, delay } : task))); + changeTasks(tasks.map((task) => (task.uuid === targetTask.uuid ? { ...task, delay } : task))); }; const deleteDelay = (targetTask: ITemplateTask) => () => { From 25aed62f55f67a062bc9abc0262ace7f593c505d Mon Sep 17 00:00:00 2001 From: "macroscopeapp[bot]" <170038800+macroscopeapp[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:03:24 +0000 Subject: [PATCH 37/39] Fix Formik values reset on patchTemplate failure in handleChangeIsActive (#253) Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- .../TemplateControlls/TemplateControlls.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index c62848f78..889759ef3 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -59,7 +59,7 @@ export function TemplateControlls({ const intl = useIntl(); const { formatMessage } = intl; const dispatch = useDispatch(); - const { values: template, setFieldValue } = useTemplateField(); + const { values: template, setFieldValue, setValues } = useTemplateField(); const { consumePendingChanges, confirmConsumedChanges, @@ -117,12 +117,16 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { + const valuesSnapshot = template; const pendingChanges = consumePendingChanges({ isActive: false }); patchTemplate({ changedFields: { ...pendingChanges, isActive: false }, onSuccess: confirmConsumedChanges, - onFailed: revertConsumedChanges, + onFailed: () => { + setValues(valuesSnapshot); + revertConsumedChanges(); + }, }); return; } @@ -139,6 +143,7 @@ export function TemplateControlls({ setIsTemplateActivating(true); + const valuesSnapshot = template; const pendingChanges = consumePendingChanges({ isActive: true }); patchTemplate({ @@ -155,6 +160,7 @@ export function TemplateControlls({ } }, onFailed: () => { + setValues(valuesSnapshot); revertConsumedChanges(); setIsTemplateActivating(false); }, From c10784af08c8226f57552164786a7b7005be51c8 Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 20:16:26 +0300 Subject: [PATCH 38/39] 59876 fix(template): stabilize create task open and defer discard abandon --- .../TemplateControlls/TemplateControlls.tsx | 2 +- .../__tests__/useTemplateEditTasks.test.ts | 35 +++++++++++++++++++ .../TemplateEdit/useTemplateEditInit.ts | 6 ++-- .../TemplateEdit/useTemplateEditTasks.ts | 14 +++++--- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index c62848f78..9602d721d 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -229,10 +229,10 @@ export function TemplateControlls({ type="button" className={classnames('cancel-button', styles['keep-draf-button'])} onClick={() => { - abandonPendingChanges(); dispatch(discardTemplateChanges({ templateId, onSuccess: () => { + abandonPendingChanges(); reject(); }, })); diff --git a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts index b047bba1b..2f339a428 100644 --- a/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts @@ -84,4 +84,39 @@ describe('useTemplateEditTasks', () => { false, ); }); + + it('keeps a stable openTask reference and does not re-open an already opened task', () => { + const task = makeTask(); + const setFieldValue = jest.fn(); + const openTaskRefs: Array<(taskUUID?: string) => void> = []; + + function Harness() { + const { openTask } = useTemplateEditTasks({ + authUser: { account: { id: 1 } } as any, + formik: { values: makeTemplate([task]) } as FormikProps, + setFieldValue, + users: [], + accessConditions: true, + formatMessage: intlMock.formatMessage, + isSubscribed: true, + }); + + openTaskRefs.push(openTask); + return null; + } + + const { rerender } = render(React.createElement(Harness)); + + act(() => { + openTaskRefs[0]!(task.uuid); + }); + + rerender(React.createElement(Harness)); + + expect(openTaskRefs[0]).toBe(openTaskRefs[1]); + + act(() => { + openTaskRefs[1]!(task.uuid); + }); + }); }); diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts index 11c2d7215..d571fa6eb 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -95,11 +95,13 @@ export function useTemplateEditInit({ }; }, []); + const firstTaskUuid = formik.values.tasks[0]?.uuid; + useEffect(() => { if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { - openTask(formik.values.tasks[0]?.uuid); + openTask(firstTaskUuid); } - }, [formik.values.tasks, location.pathname, openTask]); + }, [firstTaskUuid, location.pathname, openTask]); useEffect(() => { // Derive variables from Formik, not the Redux `template` prop. Kickoff/task diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts index e4be8ccf8..380715bde 100644 --- a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { FormikProps } from 'formik'; import { IntlShape } from 'react-intl'; @@ -43,10 +43,16 @@ export function useTemplateEditTasks({ setFieldValue('tasks', newTasks, false); }; - const openTask = (taskUUID?: string) => { + const openTask = useCallback((taskUUID?: string) => { if (!taskUUID) return; - setOpenedTasks((prev) => ({ ...prev, [taskUUID]: true })); - }; + setOpenedTasks((prev) => { + if (prev[taskUUID]) { + return prev; + } + + return { ...prev, [taskUUID]: true }; + }); + }, []); const toggleIsOpenTask = (taskUUID: string) => { setOpenedTasks((prev) => ({ ...prev, [taskUUID]: !prev[taskUUID] })); From f0e60d974b56b67b7e0325d1095359293343787f Mon Sep 17 00:00:00 2001 From: aicarma-artyom-maslov Date: Thu, 2 Jul 2026 20:42:12 +0300 Subject: [PATCH 39/39] 59876 fix(template): preserve in-flight edits when activation toggle fails --- .../TemplateControlls/TemplateControlls.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index 0e8954b5c..9602d721d 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx @@ -59,7 +59,7 @@ export function TemplateControlls({ const intl = useIntl(); const { formatMessage } = intl; const dispatch = useDispatch(); - const { values: template, setFieldValue, setValues } = useTemplateField(); + const { values: template, setFieldValue } = useTemplateField(); const { consumePendingChanges, confirmConsumedChanges, @@ -117,16 +117,12 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { - const valuesSnapshot = template; const pendingChanges = consumePendingChanges({ isActive: false }); patchTemplate({ changedFields: { ...pendingChanges, isActive: false }, onSuccess: confirmConsumedChanges, - onFailed: () => { - setValues(valuesSnapshot); - revertConsumedChanges(); - }, + onFailed: revertConsumedChanges, }); return; } @@ -143,7 +139,6 @@ export function TemplateControlls({ setIsTemplateActivating(true); - const valuesSnapshot = template; const pendingChanges = consumePendingChanges({ isActive: true }); patchTemplate({ @@ -160,7 +155,6 @@ export function TemplateControlls({ } }, onFailed: () => { - setValues(valuesSnapshot); revertConsumedChanges(); setIsTemplateActivating(false); },