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/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 93e16629a..4b669a802 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx @@ -1,276 +1,82 @@ import * as React from 'react'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; -import { useIntl } from 'react-intl'; +import { useMemo, useRef } from 'react'; -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 { TPatchTaskPayload } from '../../../redux/actions'; +import { ITemplateTask } from '../../../types/template'; +import { TTaskFormPart } from '../types'; -import { ReturnTo } from './ReturnTo'; -import { DueDate } from './DueDate'; -import { getSingleLineVariables, getSystemVariables } from './utils/getTaskVariables'; +import { getSystemVariables, getTaskVariables, getVariables } from './utils/getTaskVariables'; +import { TaskFormHeader } from './TaskFormHeader'; +import { TaskFormSections } from './TaskFormSections'; +import { TaskFormScopeProvider, useTemplateField } from '../useTemplateForm'; 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[]; task: ITemplateTask; users: TUserListItem[]; isSubscribed: boolean; scrollTarget: TTaskFormPart; accountId: number; isTeamInvitesModalOpen: boolean; - tasks: ITemplateTask[]; - kickoff: IKickoff; - patchTask(args: TPatchTaskPayload): void; } export function TaskForm({ - listVariables, - templateVariables, task, users, isSubscribed, accountId, scrollTarget, isTeamInvitesModalOpen, - tasks, - kickoff, - patchTask, - templateId, -}: ITaskFormProps & { templateId: number | undefined }) { +}: ITaskFormProps) { if (!task) return null; - const { formatMessage } = useIntl(); + const wrapperRef = useRef(null); - const taskName = task.name || ''; + // `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, ], [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 }); - }; - - 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..a93a84667 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormHeader.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; + +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; + listSystemVariables: TTaskVariable[]; + templateVariables: TTaskVariable[]; +} + +export function TaskFormHeader({ + accountId, + listSystemVariables, + templateVariables, +}: ITaskFormHeaderProps) { + const { formatMessage } = useIntl(); + const { task, updateField } = useTaskForm(); + + return ( +
+ { + updateField('name')(value); + + return Promise.resolve(value); + }} + className={styles['task-name-field']} + toolipText={formatMessage({ id: 'tasks.task-description-button-tooltip' })} + /> + { + updateField('description')(value); + + return Promise.resolve(value); + }} + 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 new file mode 100644 index 000000000..2f864d64b --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskFormSections.tsx @@ -0,0 +1,84 @@ +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 { scrollToElement } from '../../../utils/helpers'; +import { ShowMore } from '../../UI/ShowMore'; +import { useTaskFormParts } from './useTaskFormParts'; + +import styles from '../TemplateEdit.css'; + +interface ITaskFormSectionsProps { + accountId: number; + isSubscribed: boolean; + isTeamInvitesModalOpen: boolean; + kickoff: IKickoff; + listVariables: TTaskVariable[]; + scrollTarget: TTaskFormPart; + tasks: ITemplateTask[]; + templateId: number | undefined; + users: TUserListItem[]; + wrapperRef: React.RefObject; +} + +export function TaskFormSections({ + accountId, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + scrollTarget, + 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, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + isFieldsSectionShown: ETaskFormParts.Fields === scrollTarget, + 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/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 11d95c8a0..de1b64502 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx @@ -1,11 +1,13 @@ /// 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 { ITemplate, ITemplateTask } from '../../../../types/template'; import { TaskPerformers } from '../TaskPerformers'; +import { TaskFormScopeProvider, TemplateFieldContext } from '../../useTemplateForm'; jest.mock('../../../UI/Fields/Checkbox', () => ({ Checkbox: jest.fn(() => null), @@ -40,8 +42,6 @@ describe('TaskPerformers', () => { const t = (id: string) => intlMock.formatMessage({ id }); const SKIP_LABEL = t('templates.task-skip-for-starter'); - const mockSetCurrentTask = jest.fn(); - const makeTask = (overrides: Partial = {}): ITemplateTask => ({ apiName: 'task-1', number: 1, @@ -61,62 +61,54 @@ describe('TaskPerformers', () => { ...overrides, }); - const renderComponent = (taskOverrides: Partial = {}) => { - const task = makeTask(taskOverrides); - return render( - React.createElement(TaskPerformers, { - task, - tasks: [task], - users: [], - variables: [], - isTeamInvitesModalOpen: false, - setCurrentTask: mockSetCurrentTask, - }), - ); - }; + 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 getSkipCheckboxCall = () => { - const calls = (Checkbox as jest.Mock).mock.calls; - return calls.find( - (c: any[]) => c[0].checkboxId === 'skipForStarter-task-1', - ); - }; + const createTaskFormWrapper = (task: ITemplateTask, setFieldValue = jest.fn()) => { + function TaskFormTestWrapper({ children }: { children: React.ReactNode }) { + const formik = useFormik({ + initialValues: makeTemplate([task]), + enableReinitialize: true, + onSubmit: () => undefined, + }); - const getCompleteByAllCall = () => { - const calls = (Checkbox as jest.Mock).mock.calls; - return calls.find( - (c: any[]) => c[0].checkboxId === 'completeByAll-task-1', - ); - }; + return ( + + + {children} + + + ); + } - const renderTwoTasks = () => { - const task1 = makeTask({ apiName: 'task-1' }); - const task2 = makeTask({ apiName: 'task-2' }); - const setCurrentTask1 = jest.fn(); - const setCurrentTask2 = jest.fn(); - - 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, - }), - ), - ); - - return { setCurrentTask1, setCurrentTask2 }; + return TaskFormTestWrapper; }; beforeEach(() => { @@ -125,7 +117,22 @@ describe('TaskPerformers', () => { describe('checkbox identifiers', () => { it('generates unique checkboxId using task.apiName to prevent HTML id collisions', () => { - renderComponent({ apiName: 'custom-task-123' }); + const task = makeTask({ apiName: 'custom-task-123' }); + 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; const completeByAllCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('completeByAll-')); @@ -138,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(completeByAllIds).toEqual(['completeByAll-task-1', 'completeByAll-task-2']); - expect(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; @@ -165,91 +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); + + 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![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); + + 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![0].checked).toBe(false); }); - it('calls setCurrentTask with requireCompletionByAll on onChange', () => { - 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); + + 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'); const onChangeFn = call![0].onChange; - onChangeFn({ currentTarget: { checked: true } }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { requireCompletionByAll: true }, - ); + act(() => { + onChangeFn({ currentTarget: { checked: true } }); + }); + await flushPersist(); + + 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); + + 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(false); }); - it('calls setCurrentTask with true on onChange', () => { - 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; - onChangeFn({ currentTarget: { checked: true } }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { skipForStarter: true }, - ); + act(() => { + onChangeFn({ currentTarget: { checked: true } }); + }); + await flushPersist(); + + expect(setFieldValue).toHaveBeenCalledWith('tasks.0', { ...task, skipForStarter: true }, false); }); - it('calls setCurrentTask with false on checkbox uncheck', () => { - 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); + + 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; - onChangeFn({ currentTarget: { checked: false } }); - expect(mockSetCurrentTask).toHaveBeenCalledWith( - { skipForStarter: false }, - ); + act(() => { + onChangeFn({ currentTarget: { checked: false } }); + }); + await flushPersist(); + + 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 new file mode 100644 index 000000000..7af82cd13 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/useTaskForm.test.tsx @@ -0,0 +1,190 @@ +/// +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import { ETaskPerformerType, ITemplate, ITemplateTask } from '../../../../types/template'; +import { TaskFormScopeProvider, TemplateFieldContext } from '../../useTemplateForm'; +import { 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 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({ + bag, + taskUuid, + children, +}: { + bag: IFormikBag; + taskUuid: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +describe('useTaskForm', () => { + it('throws when used outside ', () => { + const Spy: React.FC = () => { + useTaskForm(); + return null; + }; + + const bag: IFormikBag = { + values: makeTemplate([makeTask()]), + setFieldValue: jest.fn(), + }; + + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => + render( + + + , + ), + ).toThrow(/TaskFormScopeProvider/); + + spy.mockRestore(); + }); + + 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 }; + + let form: ReturnType | null = null; + const Spy: React.FC = () => { + form = useTaskForm(); + return null; + }; + + render( + + + , + ); + + act(() => { + form!.updateField('name')('New name'); + }); + + expect(setFieldValue).toHaveBeenCalledWith('tasks.0.name', 'New name', false); + }); + + 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 }; + + let form: ReturnType | null = null; + const Spy: React.FC = () => { + form = useTaskForm(); + return null; + }; + + render( + + + , + ); + + const newPerformers = [ + { + apiName: 'performer-1', + type: ETaskPerformerType.User, + label: 'User 1', + sourceId: '1', + }, + ]; + + act(() => { + form!.updateTask({ rawPerformers: newPerformers, requireCompletionByAll: true }); + }); + + expect(setFieldValue).toHaveBeenCalledTimes(1); + expect(setFieldValue).toHaveBeenCalledWith( + 'tasks.0', + { ...task, rawPerformers: newPerformers, requireCompletionByAll: true }, + false, + ); + }); + + 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( + + + , + ); + + expect(form!.task).toEqual(task2); + + act(() => { + form!.updateField('name')('Updated second'); + }); + + 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..6094b73e2 100644 --- a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts +++ b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts @@ -2,39 +2,23 @@ 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 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, }; }; -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 new file mode 100644 index 000000000..2f4034b35 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskForm.tsx @@ -0,0 +1,51 @@ +import { useCallback, useRef } from 'react'; + +import { ITemplateTask } from '../../../types/template'; +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 } = useTemplateField(); + const taskUuid = useTaskFormScope(); + + 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 nextTask = { ...taskRef.current, ...changedFields }; + taskRef.current = nextTask; + setFieldValue(`tasks.${index}`, nextTask, false); + }, + [index, setFieldValue], + ); + + const updateField = useCallback( + (field: K) => (value: ITemplateTask[K]) => { + const nextTask = { ...taskRef.current, [field]: value }; + taskRef.current = nextTask; + setFieldValue(`tasks.${index}.${String(field)}`, value, false); + }, + [index, setFieldValue], + ); + + if (!task) { + throw new Error(`Task with uuid "${taskUuid}" is not present in the template form state`); + } + + return { + task, + updateTask, + updateField, + }; +} 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..b03fa173a --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/TaskForm/useTaskFormParts.tsx @@ -0,0 +1,194 @@ +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 { 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 { useTaskForm } from './useTaskForm'; + +import styles from '../TemplateEdit.css'; + +interface IUseTaskFormPartsProps { + accountId: number; + isSubscribed: boolean; + isTeamInvitesModalOpen: boolean; + kickoff: IKickoff; + listVariables: TTaskVariable[]; + isFieldsSectionShown: boolean; + tasks: ITemplateTask[]; + templateId: number | undefined; + users: TUserListItem[]; +} + +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, + isSubscribed, + isTeamInvitesModalOpen, + kickoff, + listVariables, + isFieldsSectionShown, + 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' }), + apiName: 'kick-off', + type: EStartingType.Kickoff, + }, + ...tasks + .filter((localTask) => task.apiName !== localTask.apiName) + .map((localTask) => ({ + apiName: localTask.apiName, + title: localTask.name, + type: EStartingType.Task, + richSubtitle: , + })), + ], [task.apiName, formatMessage, tasks, templateId]); + + const onRemoveDeletedTasks = useCallback( + (conditions: ICondition[]) => { + updateTask({ conditions }); + }, + [updateTask], + ); + + useEffect(() => { + removeDeletedTasks(startingOrder, task.conditions, onRemoveDeletedTasks); + }, [startingOrder, task.conditions, onRemoveDeletedTasks]); + + const createWidget = useCallback( + (Component: React.ComponentType void }>, props: IWidgetProps) => { + return (toggle: () => void) => ( + + ); + }, + [], + ); + + return [ + { + 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 }), + }, + ]; +} 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 && ( - - ) + ); }; diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx index 5fcb94e9f..9602d721d 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, useTemplatePersist } 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,15 @@ export function TemplateControlls({ const intl = useIntl(); const { formatMessage } = intl; const dispatch = useDispatch(); + const { values: template, setFieldValue } = useTemplateField(); + const { + consumePendingChanges, + confirmConsumedChanges, + revertConsumedChanges, + abandonPendingChanges, + } = useTemplatePersist(); + const templateStatus = useSelector(getTemplateStatus); + const isSubscribed = useSelector(getIsUserSubsribed); const billingPlan = useSelector(getSubscriptionPlan); const isFreePlan = billingPlan === ESubscriptionPlan.Free; const accessConditions = isSubscribed || isFreePlan; @@ -112,7 +117,13 @@ export function TemplateControlls({ const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => { if (!value) { - patchTemplate({ changedFields: { isActive: false } }); + const pendingChanges = consumePendingChanges({ isActive: false }); + + patchTemplate({ + changedFields: { ...pendingChanges, isActive: false }, + onSuccess: confirmConsumedChanges, + onFailed: revertConsumedChanges, + }); return; } @@ -128,11 +139,15 @@ export function TemplateControlls({ setIsTemplateActivating(true); + const pendingChanges = consumePendingChanges({ isActive: true }); + patchTemplate({ changedFields: { + ...pendingChanges, isActive: true, }, onSuccess: () => { + confirmConsumedChanges(); setIsTemplateActivating(false); if (redirectUrl) { @@ -140,6 +155,7 @@ export function TemplateControlls({ } }, onFailed: () => { + revertConsumedChanges(); setIsTemplateActivating(false); }, }); @@ -212,7 +228,15 @@ export function TemplateControlls({ @@ -272,7 +296,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...newTemplateOwners, ...viewers, ...starters] } }) + setFieldValue('owners', [...newTemplateOwners, ...viewers, ...starters], false) } /> @@ -283,7 +307,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...pureOwners, ...newViewers, ...starters] } }) + setFieldValue('owners', [...pureOwners, ...newViewers, ...starters], false) } /> @@ -294,7 +318,7 @@ export function TemplateControlls({ - patchTemplate({ changedFields: { owners: [...pureOwners, ...viewers, ...newStarters] } }) + setFieldValue('owners', [...pureOwners, ...viewers, ...newStarters], false) } /> @@ -353,7 +377,7 @@ export function TemplateControlls({ checked={isTemplateFinalizable} checkedChildren={null} unCheckedChildren={null} - onChange={(value) => patchTemplate({ changedFields: { finalizable: value } })} + onChange={(value) => setFieldValue('finalizable', value, false)} />
@@ -368,7 +392,7 @@ export function TemplateControlls({ checked={isCompletionNotification} checkedChildren={null} unCheckedChildren={null} - onChange={(value) => patchTemplate({ changedFields: { completionNotification: value } })} + onChange={(value) => setFieldValue('completionNotification', value, false)} />
@@ -383,7 +407,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..4dc1e9edf 100644 --- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx +++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx @@ -1,73 +1,19 @@ -import React, { useState, useEffect } from 'react'; +import React, { useRef } 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'; -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 { cleanTemplateReferences, 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 { resolveTemplateFormIdentity } from './useTemplateForm/templateFormUtils'; -import styles from './TemplateEdit.css'; -import { getEmptyConditions } from './TaskForm/Conditions/utils/getEmptyConditions'; +import { 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, @@ -83,421 +29,73 @@ export function TemplateEdit({ resetTemplateStore, saveTemplate, setTemplate, - setTemplateStatus, loadTemplateVariablesSuccess, }: TTemplateEditProps) { const { formatMessage } = useIntl(); - const { tasks, owners } = template; + 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, + templateIdentity, + ); 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) { - const newTemplateOwners = getNormalizedTemplateOwners(owners, accessConditions, users); - setTemplate({ ...template, 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 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); - }; - - 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; - }); - - handleChangeTemplateField('tasks')(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 submitDebounced = debounce(350, saveTemplate); - - 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
; } return ( -
- - -
-
- -
-
- {!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..70424273b --- /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 { useTemplateSaveRetry } from './useTemplateForm'; + +import styles from './TemplateEdit.css'; + +type TTaskListItemProps = React.ComponentProps & { key: string }; + +interface ITemplateEditLayoutProps { + accessConditions: boolean; + sortedTasks(): ITemplateTask[]; + getTaskListItem(task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]): TTaskListItemProps; + handleAddTask(): void; +} + +export function TemplateEditLayout({ + accessConditions, + sortedTasks, + getTaskListItem, + handleAddTask, +}: ITemplateEditLayoutProps) { + const tasksLocal = sortedTasks(); + const retryFailedSave = useTemplateSaveRetry(); + + return ( +
+ + +
+
+ +
+
+ {!accessConditions && } +
+
+ +
+ {tasksLocal.map((task, index) => { + const { key, ...taskProps } = getTaskListItem(task, index, tasksLocal); + return ; + })} + + +
+
+
+
+ ); +} 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/__tests__/useTemplateEditTasks.test.ts b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts new file mode 100644 index 000000000..2f339a428 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateEditTasks.test.ts @@ -0,0 +1,122 @@ +/// +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, + ); + }); + + 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/__tests__/useTemplateForm.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx new file mode 100644 index 000000000..22f2b6b81 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/__tests__/useTemplateForm.test.tsx @@ -0,0 +1,1674 @@ +/// +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import { + ITemplate, + ITemplateTask, + ETaskPerformerType, +} from '../../../types/template'; +import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../TaskForm/Conditions/types'; +import { TemplateForm, useTemplateField, useTemplateForm, useTemplatePersist, useTemplateSaveRetry } from '../useTemplateForm'; +import { patchTemplate, saveTemplate, setTemplateStatus } from '../../../redux/actions'; +import { ETemplateStatus } from '../../../types/redux'; +import { resetAutosavePersistRequestsForTests } from '../../../redux/template/persistRequest'; + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => ({ + 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 => + ({ + 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; + consumePendingChanges: (explicitFields?: Partial) => Partial; + confirmConsumedChanges: () => void; + revertConsumedChanges: () => void; + abandonPendingChanges: () => 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`. 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({ + initialTemplate, + spy, +}: { + initialTemplate: ITemplate; + spy: (handle: ISpyHandle) => void; +}) { + const { formik, setFieldValue, setValues, dirtyRef, pendingUserEditsRef, persistBaselineSyncRef } = useTemplateForm(initialTemplate); + + const Spy: React.FC = () => { + const { values, setFieldValue: contextSetFieldValue } = useTemplateField(); + const { consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges } = useTemplatePersist(); + spy({ values, setFieldValue: contextSetFieldValue, consumePendingChanges, confirmConsumedChanges, revertConsumedChanges, abandonPendingChanges }); + 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(); + resetAutosavePersistRequestsForTests(); + }); + + 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); + }); + + expect(handle!.values.isActive).toBe(false); + + await flushPersist(); + 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('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, + 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); + }); + + 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; + 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, isActive: false }); + expect(patchTemplate).not.toHaveBeenCalled(); + }); + + 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; + + render( { handle = h; }} />); + + act(() => { + handle!.setFieldValue('owners', owners, false); + }); + + act(() => { + handle!.consumePendingChanges(); + handle!.revertConsumedChanges(); + }); + 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, + isActive: false, + }); + }); + + 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('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 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; + 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; + 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('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; + + 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', + isActive: false, + }); + }); + + 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('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; + + const { unmount } = render( + { handle = h; }} />, + ); + + act(() => { + handle!.setFieldValue('description', 'discarded edit', false); + handle!.abandonPendingChanges(); + }); + + act(() => { + unmount(); + }); + + await flushPersist(); + + 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 () => { + 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; + + 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); + }); +}); + +describe('useTemplateForm reinitialize', () => { + function HookHarness({ + currentTemplate, + templateIdentityKey, + onReady, + }: { + currentTemplate: ITemplate; + templateIdentityKey?: string | number; + onReady(result: ReturnType): void; + }) { + 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('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('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; + + 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'); + }); + + 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({ + isActive: false, + 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({ + isActive: false, + tasks: [expect.objectContaining({ skipForStarter: true })], + }); + 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'); + }); + + 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' }); + 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', () => { + 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(); + }); + + 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('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 }); + 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({ + isActive: false, + 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); + }); +}); + +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(); + }); + + 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('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', + 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(); + }); +}); + +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).toHaveBeenCalledWith(setTemplateStatus(ETemplateStatus.Saving)); + 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(); + }); + + 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/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..d571fa6eb --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditInit.ts @@ -0,0 +1,143 @@ +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(); + }; + }, []); + + const firstTaskUuid = formik.values.tasks[0]?.uuid; + + useEffect(() => { + if (checkSomeRouteIsActive(ERoutes.TemplatesCreate) || checkSomeRouteIsActive(ERoutes.TemplatesCreateAI)) { + openTask(firstTaskUuid); + } + }, [firstTaskUuid, location.pathname, openTask]); + + 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 }); + } + }, [location, users, template, formik.values]); +} diff --git a/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts new file mode 100644 index 000000000..380715bde --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateEditTasks.ts @@ -0,0 +1,200 @@ +import { useCallback, 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 = useCallback((taskUUID?: string) => { + if (!taskUUID) return; + setOpenedTasks((prev) => { + if (prev[taskUUID]) { + return prev; + } + + return { ...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 ? { ...task, [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 ? { ...task, 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/useTemplateForm/TemplateForm.tsx b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx new file mode 100644 index 000000000..aea5cb8bf --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateForm.tsx @@ -0,0 +1,52 @@ +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..c4c255b85 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/TemplateFormPersistProvider.tsx @@ -0,0 +1,321 @@ +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, setTemplateStatus } from '../../../redux/actions'; +import { ETemplateStatus } from '../../../types/redux'; +import { + abandonAutosavePersistRequests, + allocateAutosavePersistRequestId, + isAutosavePersistRequestCurrent, +} from '../../../redux/template/persistRequest'; +import { TemplatePersistContext, useTemplateField } from './contexts'; +import { getChangedFields, getUnconsumedPendingEdits } from './templateFormUtils'; +import { ITemplatePersistContextValue } from './types'; + +interface ITemplateFormPersistProviderProps { + dirtyRef: React.MutableRefObject; + pendingUserEditsRef: React.MutableRefObject>; + persistBaselineSyncRef: React.MutableRefObject<((reduxTemplate: ITemplate) => void) | null>; + children: React.ReactNode; +} + +type TConsumedPending = { + previousBaseline: ITemplate; + isUserEdit: boolean; + pendingUserEdits: Partial; + explicitFields?: Partial; + /** Formik snapshot covered by the in-flight autosave patch. */ + dispatchedValues?: ITemplate; +}; + +/** + * 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 { setValues } = useTemplateField(); + const dispatch = useDispatch(); + const latestReduxBaselineRef = useRef(null); + 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 consumedPendingRef = useRef(null); + 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) => { + // 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. + latestReduxBaselineRef.current = reduxTemplate; + previousValuesRef.current = { ...reduxTemplate }; + }; + + return () => { + persistBaselineSyncRefRef.current.current = null; + }; + }, []); + + const takePendingChanges = useCallback((mode: 'flush' | 'consume') => { + if (previousValuesRef.current === valuesRef.current) { + return {}; + } + + const changedFields = getChangedFields(previousValuesRef.current, valuesRef.current); + const isUserEdit = dirtyRefRef.current.current; + + if (isUserEdit && Object.keys(changedFields).length > 0) { + consumedPendingRef.current = { + previousBaseline: previousValuesRef.current, + isUserEdit, + pendingUserEdits: { ...pendingUserEditsRefRef.current.current }, + ...(mode === 'flush' ? { dispatchedValues: valuesRef.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 getRetryExplicitPatch = useCallback(() => retryExplicitPatchRef.current, []); + + const confirmConsumedChanges = useCallback(() => { + const consumed = consumedPendingRef.current; + + if (consumed) { + pendingUserEditsRefRef.current.current = getUnconsumedPendingEdits( + consumed.pendingUserEdits, + pendingUserEditsRefRef.current.current, + ); + } + + consumedPendingRef.current = null; + retryExplicitPatchRef.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; + } + + if (previousValuesRef.current !== valuesRef.current) { + flushPersistRef.current(); + } + }, []); + + const revertConsumedChanges = useCallback(() => { + const consumed = consumedPendingRef.current; + + if (!consumed) { + return; + } + + 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; + + 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); + } + } + + // 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) => { + 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 (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) { + const requestId = allocateAutosavePersistRequestId(); + pendingDispatchRef.current = { requestId, dispatchedValues: valuesRef.current }; + dispatchRef.current( + patchTemplate({ + changedFields, + requestId, + onSuccess: () => { + if (!isAutosavePersistRequestCurrent(requestId)) { + return; + } + pendingDispatchRef.current = null; + confirmConsumedChanges(); + }, + onFailed: () => { + if (!isAutosavePersistRequestCurrent(requestId)) { + return; + } + pendingDispatchRef.current = null; + revertConsumedChanges(); + }, + }), + ); + } + }, [takePendingChanges, confirmConsumedChanges, revertConsumedChanges]); + + flushPersistRef.current = flushPersist; + + 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; + pendingUserEditsRefRef.current.current = {}; + consumedPendingRef.current = null; + retryExplicitPatchRef.current = {}; + pendingDispatchRef.current = null; + }, []); + + useEffect(() => { + if (previousValuesRef.current === values) { + 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); + + return () => { + window.clearTimeout(timeoutId); + flushPersist(); + }; + }, [values, flushPersist]); + + const persistContextValue = useMemo( + () => ({ + consumePendingChanges, + getRetryExplicitPatch, + confirmConsumedChanges, + revertConsumedChanges, + abandonPendingChanges, + }), + [consumePendingChanges, getRetryExplicitPatch, 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..9dc6b2bff --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/index.ts @@ -0,0 +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 new file mode 100644 index 000000000..551c963a0 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormDeactivation.ts @@ -0,0 +1,50 @@ +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 +// 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')) { + const kickoffChanged = changedFields.kickoff; + const previousKickoff = previousTemplate.kickoff; + + if (haveSameKickoffFields(kickoffChanged?.fields, previousKickoff.fields)) { + shouldDeactivate = + kickoffChanged?.description === previousKickoff.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..be05aa905 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormReferenceCleanup.ts @@ -0,0 +1,68 @@ +import { IKickoff, ITemplate, ITemplateTask } from '../../../types/template'; +import { cleanTemplateReferences } from '../../../utils/template'; + +function getKickoffFieldApiNames(kickoff: IKickoff): string[] { + return (kickoff.fields || []) + .map((field) => field.apiName) + .filter(Boolean) + .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); + + return previousNames.length !== nextNames.length + || 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') { + return true; + } + + if (field === 'kickoff') { + 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; +} + +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..c03bbe882 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/templateFormUtils.ts @@ -0,0 +1,185 @@ +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+)(?:\.(.+))?$/); + + 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. + const isCreateSessionIdentity = (identity: string | number | undefined) => + identity === undefined + || identity === 'create' + || (typeof identity === 'string' && identity.startsWith('create:')); + + if (isCreateSessionIdentity(previousIdentity) && 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..c6a24bb53 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/types.ts @@ -0,0 +1,22 @@ +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(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. */ + 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..a2fda22f4 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateFormHook.ts @@ -0,0 +1,121 @@ +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 }); + persistBaselineSyncRef.current?.(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 }; +} 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..3b0c58971 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/useTemplateForm/useTemplateSaveRetry.ts @@ -0,0 +1,43 @@ +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, + getRetryExplicitPatch, + confirmConsumedChanges, + revertConsumedChanges, + } = useTemplatePersist(); + + return useCallback(() => { + const pendingChanges = consumePendingChanges(); + const changedFields = { + ...pendingChanges, + ...getRetryExplicitPatch(), + }; + + if (Object.keys(changedFields).length > 0) { + dispatch( + patchTemplate({ + changedFields, + onSuccess: confirmConsumedChanges, + onFailed: revertConsumedChanges, + }), + ); + return; + } + + dispatch(saveTemplate()); + }, [consumePendingChanges, confirmConsumedChanges, dispatch, getRetryExplicitPatch, 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..3d44e0485 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/createTemplateEditTask.test.ts @@ -0,0 +1,36 @@ +import { IAuthUser } from '../../../../types/redux'; +import { TUserListItem } from '../../../../types/user'; +import { ETemplateOwnerRole } from '../../../../types/template'; +import { createEmptyTemplate, 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'); + }); +}); + +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 new file mode 100644 index 000000000..24529b491 --- /dev/null +++ b/frontend/src/public/components/TemplateEdit/utils/createTemplateEditTask.ts @@ -0,0 +1,97 @@ +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(); + + const task = { + 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), + checklists: [], + ...templateTask, + revertTask: null, + ancestors: [], + }; + + return { + ...task, + rawDueDate: createEmptyTaskDueDate(task.apiName), + }; +} + +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; +} 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(); + }); +}); 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 582cac366..85c670d44 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'; @@ -54,7 +55,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'; @@ -66,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); @@ -97,19 +127,28 @@ 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)); 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 = - 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 = { @@ -123,7 +162,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) { @@ -186,7 +230,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, @@ -197,13 +241,27 @@ 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); 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; + } + const lastTemplateState: ReturnType = yield select(getTemplateData); if (!savedTemplate) { @@ -214,22 +272,11 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) { 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)); @@ -361,7 +408,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); } } 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,