Skip to content

Commit 62bd0d3

Browse files
authored
Merge pull request #201 from pneumaticapp/backend/template/45972__auto_cleaning_broken_links
45972 backend [ templates ] Auto-cleaning of broken links to fields in templates
2 parents e6c5e25 + 177c4d9 commit 62bd0d3

8 files changed

Lines changed: 325 additions & 15 deletions

File tree

frontend/src/public/components/RichEditor/RichEditor.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ export const RichEditor = forwardRef<
190190
});
191191
}, []);
192192

193+
const replaceContent = useCallback((markdown: string): void => {
194+
const editor = editorRef.current;
195+
if (!editor) return;
196+
applyMarkdownToEditor(editor, markdown, {
197+
tag: 'history-merge',
198+
templateVariables: templateVariablesRef.current,
199+
});
200+
}, []);
201+
193202
useImperativeHandle(
194203
ref,
195204
() => ({
@@ -204,8 +213,9 @@ export const RichEditor = forwardRef<
204213
return editorRef.current ?? undefined;
205214
},
206215
clearContent,
216+
replaceContent,
207217
}),
208-
[insertVariableToEditor, clearContent],
218+
[insertVariableToEditor, clearContent, replaceContent],
209219
);
210220

211221

frontend/src/public/components/RichEditor/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface IRichEditorHandle {
1212
insertVariable(apiName: string, variableTitle: string, subtitle?: string): void;
1313
getEditor?(): LexicalEditor | undefined;
1414
clearContent(): void;
15+
replaceContent(markdown: string): void;
1516
}
1617

1718
export type TMentionData = {

frontend/src/public/components/TemplateEdit/InputWithVariables/InputWithVariables.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,24 @@ export const InputWithVariables: React.FC<IEditorWithVariablesProps> = ({
3434
onChange,
3535
}) => {
3636
const editorRef = React.useRef<IRichEditorHandle>(null);
37-
const formattedValue = escapeMarkdown(value);
37+
const lastEmittedValue = React.useRef<string | undefined>(value);
38+
39+
const handleChange: IRichEditorProps['handleChange'] = React.useCallback(
40+
(markdown: string) => {
41+
lastEmittedValue.current = markdown;
42+
43+
return onChange(markdown);
44+
},
45+
[onChange],
46+
);
47+
48+
React.useEffect(() => {
49+
if (value !== lastEmittedValue.current) {
50+
lastEmittedValue.current = value;
51+
const formattedValue = escapeMarkdown(value);
52+
editorRef.current?.replaceContent(formattedValue ?? '');
53+
}
54+
}, [value]);
3855

3956
const handleInsertVariable = (apiName?: string) => (e: React.MouseEvent) => {
4057
e.stopPropagation();
@@ -52,8 +69,8 @@ export const InputWithVariables: React.FC<IEditorWithVariablesProps> = ({
5269
ref={editorRef}
5370
title={title}
5471
placeholder={placeholder ?? ''}
55-
defaultValue={formattedValue}
56-
handleChange={onChange}
72+
defaultValue={escapeMarkdown(value)}
73+
handleChange={handleChange}
5774
withToolbar={false}
5875
withMentions={false}
5976
multiline={false}

frontend/src/public/components/TemplateEdit/TaskForm/TaskDescriptionEditor.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useRef } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
22
import { useIntl } from 'react-intl';
33
import { useSelector } from 'react-redux';
44

@@ -31,13 +31,30 @@ export function TaskDescriptionEditor({
3131
}: ITaskDescriptionEditorProps) {
3232
const { formatMessage } = useIntl();
3333
const editorRef = useRef<IRichEditorHandle>(null);
34+
const lastEmittedValue = useRef<string | undefined>(value);
3435

3536
const users = useSelector(getUsers);
3637
const mentions = useMemo(
3738
() => getMentionData(getNotDeletedUsers(users)),
3839
[users],
3940
);
4041

42+
const wrappedHandleChange = useCallback(
43+
(markdown: string) => {
44+
lastEmittedValue.current = markdown;
45+
46+
return handleChange(markdown);
47+
},
48+
[handleChange],
49+
);
50+
51+
useEffect(() => {
52+
if (value !== lastEmittedValue.current) {
53+
lastEmittedValue.current = value;
54+
editorRef.current?.replaceContent(value ?? '');
55+
}
56+
}, [value]);
57+
4158
const handleInsertVariable = (apiName?: string) => (e: React.MouseEvent) => {
4259
e.stopPropagation();
4360

@@ -62,7 +79,7 @@ export function TaskDescriptionEditor({
6279
title={formatMessage({ id: 'tasks.task-description-field' })}
6380
placeholder={formatMessage({ id: 'template.task-description-placeholder' })}
6481
defaultValue={value}
65-
handleChange={handleChange}
82+
handleChange={wrappedHandleChange}
6683
handleChangeChecklists={handleChangeChecklists}
6784
withChecklists
6885
mentions={mentions}

frontend/src/public/components/TemplateEdit/TemplateEdit.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getVariables } from './TaskForm/utils/getTaskVariables';
1414
import { TemplateIntegrations } from './Integrations';
1515
import { ERoutes } from '../../constants/routes';
1616
import { TUserListItem } from '../../types/user';
17-
import { getEmptyKickoff, getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template';
17+
import { cleanTemplateReferences, getEmptyKickoff, getNormalizedTemplateOwners, getTemplateIdFromUrl } from '../../utils/template';
1818
import { checkSomeRouteIsActive, isCreateTemplate } from '../../utils/history';
1919
import { KickoffReduxContainer } from './KickoffRedux';
2020
import { moveTask } from '../../utils/workflows';
@@ -269,12 +269,16 @@ export function TemplateEdit({
269269
return;
270270
}
271271

272-
const newWorkflow: ITemplate = {
272+
const updatedWorkflow: ITemplate = {
273273
...workflow,
274274
[field]: value,
275275
isActive: false,
276276
};
277277

278+
const newWorkflow = (field === 'kickoff' || field === 'tasks')
279+
? cleanTemplateReferences(updatedWorkflow)
280+
: updatedWorkflow;
281+
278282
setTemplate(newWorkflow);
279283
submitDebounced();
280284
};

frontend/src/public/redux/template/saga.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import { ITemplate, ITemplateRequest, ITemplateResponse } from '../../types/temp
5454
import { logger } from '../../utils/logger';
5555
import { NotificationManager } from '../../components/UI/Notifications';
5656
import { updateTemplate } from '../../api/updateTemplate';
57-
import { getNormalizedTemplate, mapTemplateRequest } from '../../utils/template';
57+
import { cleanTemplateReferences, getNormalizedTemplate, mapTemplateRequest } from '../../utils/template';
5858
import { getErrorMessage, isPaidFeatureError } from '../../utils/getErrorMessage';
5959
import { insertId } from '../../utils/templates/insertId';
6060
import { ETemplateStatus } from '../../types/redux';
@@ -112,12 +112,15 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }:
112112
changedFields.kickoff?.description === template.kickoff.description ? shouldDeactivateTemplate : false;
113113
}
114114

115-
const newTemplate: ITemplate = {
115+
const mergedTemplate: ITemplate = {
116116
...template,
117117
...changedFields,
118118
...(shouldDeactivateTemplate && { isActive: false }),
119119
};
120120

121+
const needsCleanup = changedFields.hasOwnProperty('tasks') || changedFields.hasOwnProperty('kickoff');
122+
const newTemplate = needsCleanup ? cleanTemplateReferences(mergedTemplate) : mergedTemplate;
123+
121124
yield put(setTemplate(newTemplate));
122125
yield delay(350);
123126
yield put(saveTemplate({ onSuccess, onFailed }));

frontend/src/public/utils/__tests__/template.test.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import {
44
ETaskPerformerType,
55
ITemplateResponse,
66
ITemplate,
7+
EExtraFieldType,
8+
IExtraField,
79
} from '../../types/template';
810
import { ESubscriptionPlan } from '../../types/account';
911
import { TUserListItem, EUserStatus } from '../../types/user';
10-
import { getNormalizedTemplate, mapTemplateRequest, getEmptyKickoff } from '../template';
12+
import { getNormalizedTemplate, mapTemplateRequest, getEmptyKickoff, cleanTemplateReferences } from '../template';
13+
import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../../components/TemplateEdit/TaskForm/Conditions/types';
1114

1215
const createMockUser = (overrides: Partial<TUserListItem> = {}): TUserListItem => ({
1316
id: 1,
@@ -367,5 +370,171 @@ describe('template utilities', () => {
367370

368371
expect(result.publicSuccessUrl).toBeNull();
369372
});
373+
describe('cleanTemplateReferences', () => {
374+
it('removes invalid field references from template and task text fields but preserves system vars', () => {
375+
const template = createMockTemplate({
376+
wfNameTemplate: 'Name: {{valid-field}} and {{invalid-field}} and {{template-name}}',
377+
kickoff: {
378+
...getEmptyKickoff(),
379+
fields: [{ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid Field', order: 1 } as unknown as IExtraField],
380+
},
381+
tasks: [
382+
{
383+
...createMockTemplate().tasks[0],
384+
name: 'Task {{invalid-field}} and {{valid-field}}',
385+
description: 'Desc {{workflow-starter}} and {{invalid-field}}',
386+
number: 1,
387+
fields: [{ apiName: 'valid-task-field', type: EExtraFieldType.Text, name: 'Valid Task Field', order: 1 } as unknown as IExtraField],
388+
},
389+
{
390+
...createMockTemplate().tasks[0],
391+
name: 'Task 2 {{valid-field}} and {{valid-task-field}} and {{broken}}',
392+
description: 'Desc',
393+
number: 2,
394+
},
395+
],
396+
});
397+
398+
const cleaned = cleanTemplateReferences(template);
399+
400+
// wfNameTemplate: {{template-name}} is a WF_NAME system var, preserved; {{invalid-field}} removed
401+
expect(cleaned.wfNameTemplate).toBe('Name: {{valid-field}} and and {{template-name}}');
402+
expect(cleaned.tasks[0].name).toBe('Task and {{valid-field}}');
403+
// {{workflow-starter}} is a TASK system var, preserved; {{invalid-field}} removed
404+
expect(cleaned.tasks[0].description).toBe('Desc {{workflow-starter}} and ');
405+
// Task 2 variables should successfully retain valid fields from kickoff and task 1
406+
expect(cleaned.tasks[1].name).toBe('Task 2 {{valid-field}} and {{valid-task-field}} and ');
407+
});
408+
409+
it('removes invalid field references from conditions', () => {
410+
const template = createMockTemplate({
411+
tasks: [
412+
{
413+
...createMockTemplate().tasks[0],
414+
conditions: [
415+
{
416+
apiName: 'cond-1',
417+
order: 1,
418+
action: EConditionAction.StartTask,
419+
rules: [
420+
{ field: 'valid-field', operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '1' } as unknown as TConditionRule,
421+
{ field: 'invalid-field', operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '2' } as unknown as TConditionRule,
422+
],
423+
},
424+
],
425+
},
426+
],
427+
kickoff: {
428+
...getEmptyKickoff(),
429+
fields: [{ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid', order: 1 } as unknown as IExtraField],
430+
},
431+
});
432+
433+
const cleaned = cleanTemplateReferences(template);
434+
const rules = cleaned.tasks[0].conditions[0].rules;
435+
expect(rules).toHaveLength(1);
436+
expect(rules[0].field).toBe('valid-field');
437+
});
438+
439+
it('allows task name to become empty if all variables are invalid', () => {
440+
const template = createMockTemplate({
441+
tasks: [
442+
{
443+
...createMockTemplate().tasks[0],
444+
number: 2,
445+
name: '{{invalid-field}}',
446+
},
447+
],
448+
});
449+
const cleaned = cleanTemplateReferences(template);
450+
expect(cleaned.tasks[0].name).toBe('');
451+
});
452+
453+
it('safely handles empty rawPerformers array or invalid field performers and preserves valid members', () => {
454+
const template = createMockTemplate({
455+
kickoff: {
456+
...getEmptyKickoff(),
457+
fields: [{ apiName: 'valid-user-field', type: EExtraFieldType.User, name: 'Valid User', order: 1 } as unknown as IExtraField],
458+
},
459+
tasks: [
460+
{
461+
...createMockTemplate().tasks[0],
462+
rawPerformers: [
463+
{ type: ETaskPerformerType.OutputUser, sourceId: 'invalid-field', label: 'Broken', apiName: '1' },
464+
{ type: ETaskPerformerType.OutputUser, sourceId: 'valid-user-field', label: 'Valid User', apiName: '2' },
465+
{ type: ETaskPerformerType.User, sourceId: '1', label: 'Regular User', apiName: '3' },
466+
],
467+
},
468+
],
469+
});
470+
const cleaned = cleanTemplateReferences(template);
471+
expect(cleaned.tasks[0].rawPerformers).toHaveLength(2);
472+
expect(cleaned.tasks[0].rawPerformers.map((p) => p.apiName)).toEqual(['2', '3']);
473+
});
474+
475+
it('removes invalid field references from rawDueDate', () => {
476+
const template = createMockTemplate({
477+
kickoff: {
478+
...getEmptyKickoff(),
479+
fields: [{ apiName: 'valid-date-field', type: EExtraFieldType.Date, name: 'Valid Date', order: 1 } as unknown as IExtraField],
480+
},
481+
tasks: [
482+
{
483+
...createMockTemplate().tasks[0],
484+
number: 1,
485+
rawDueDate: {
486+
apiName: 'due-1', duration: null, durationMonths: null, rulePreposition: 'after', ruleTarget: 'field', sourceId: 'valid-date-field',
487+
},
488+
},
489+
{
490+
...createMockTemplate().tasks[0],
491+
number: 2,
492+
rawDueDate: {
493+
apiName: 'due-2', duration: null, durationMonths: null, rulePreposition: 'after', ruleTarget: 'field', sourceId: 'invalid-field',
494+
},
495+
},
496+
]
497+
});
498+
const cleaned = cleanTemplateReferences(template);
499+
expect(cleaned.tasks[0].rawDueDate?.sourceId).toBe('valid-date-field');
500+
expect(cleaned.tasks[0].rawDueDate?.ruleTarget).toBe('field');
501+
expect(cleaned.tasks[1].rawDueDate?.duration).toBeNull();
502+
expect(cleaned.tasks[1].rawDueDate?.durationMonths).toBeNull();
503+
expect(cleaned.tasks[1].rawDueDate?.sourceId).toBeNull();
504+
expect(cleaned.tasks[1].rawDueDate?.ruleTarget).toBe('task started');
505+
});
506+
it('preserves Start After condition rules and handles empty fields', () => {
507+
const template = createMockTemplate({
508+
tasks: [
509+
{
510+
...createMockTemplate().tasks[0],
511+
conditions: [
512+
{
513+
apiName: 'cond-1',
514+
order: 1,
515+
action: EConditionAction.StartTask,
516+
rules: [
517+
{ field: 'task-123', fieldType: 'task', operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '1' } as unknown as TConditionRule,
518+
{ field: '', operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '2' } as unknown as TConditionRule,
519+
{ field: undefined, operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '3' } as unknown as TConditionRule,
520+
{ field: null, operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '4' } as unknown as TConditionRule,
521+
{ field: 'invalid-field', fieldType: 'field', operator: EConditionOperators.Equal, logicOperation: EConditionLogicOperations.And, predicateApiName: '5' } as unknown as TConditionRule,
522+
],
523+
},
524+
],
525+
},
526+
],
527+
});
528+
529+
const cleaned = cleanTemplateReferences(template);
530+
const rules = cleaned.tasks[0].conditions[0].rules;
531+
532+
expect(rules).toHaveLength(4);
533+
expect(rules[0].field).toBe('task-123');
534+
expect(rules[1].field).toBe('');
535+
expect(rules[2].field).toBeUndefined();
536+
expect(rules[3].field).toBeNull();
537+
});
370538
});
371539
});
540+
});

0 commit comments

Comments
 (0)