Skip to content

Commit 066412f

Browse files
authored
feat: adds ability for form creator to duplicate Page and all patterns within it (#622)
1 parent d7caad3 commit 066412f

5 files changed

Lines changed: 210 additions & 5 deletions

File tree

packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ export const PatternEditActions = ({ children }: PatternEditActionsProps) => {
3535
focusPatternType === 'repeater' || focusPatternType === 'fieldset';
3636
const isSummary = focusPatternType === 'form-summary';
3737
const isPagePattern = focusPatternType === 'page';
38-
const { copyPattern } = useFormManagerStore(state => ({
38+
const { copyPattern, copyPage } = useFormManagerStore(state => ({
3939
copyPattern: state.copyPattern,
40+
copyPage: state.copyPage,
4041
}));
4142
const pages = useFormManagerStore(state =>
4243
Object.values<Pattern>(state.session.form.patterns).filter(
@@ -67,6 +68,12 @@ export const PatternEditActions = ({ children }: PatternEditActionsProps) => {
6768
}
6869
};
6970

71+
const handleCopyPage = () => {
72+
if (focusPatternId && isPagePattern) {
73+
copyPage(focusPatternId);
74+
}
75+
};
76+
7077
return (
7178
<div
7279
className={`${styles.patternActionWrapper} margin-top-2 margin-bottom-1 padding-top-1 width-full pattern-edit-panel base-dark text-right`}
@@ -90,12 +97,20 @@ export const PatternEditActions = ({ children }: PatternEditActionsProps) => {
9097
>
9198
<button
9299
type="button"
93-
aria-label="Create a copy of this pattern"
94-
title="Create a copy of this pattern"
100+
aria-label={
101+
isPagePattern
102+
? 'Create a copy of this page'
103+
: 'Create a copy of this pattern'
104+
}
105+
title={
106+
isPagePattern
107+
? 'Create a copy of this page'
108+
: 'Create a copy of this pattern'
109+
}
95110
className="usa-button--outline usa-button--unstyled"
96111
onClick={event => {
97112
event.preventDefault();
98-
handleCopyPattern();
113+
isPagePattern ? handleCopyPage() : handleCopyPattern();
99114
}}
100115
>
101116
<svg

packages/design/src/FormManager/FormEdit/store.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type FormEditSlice = {
3131
) => void;
3232
clearFocus: () => void;
3333
copyPattern: (parentPatternId: PatternId, patternId: PatternId) => void;
34+
copyPage: (pageId: PatternId) => void;
3435
deletePattern: (id: PatternId) => void;
3536
deleteSelectedPattern: () => void;
3637
movePattern: (
@@ -121,6 +122,22 @@ export const createFormEditSlice =
121122
});
122123
state.addNotification('success', 'Element copied successfully.');
123124
},
125+
126+
copyPage: (pageId: PatternId) => {
127+
const state = get();
128+
const builder = new BlueprintBuilder(
129+
state.context.config,
130+
state.session.form
131+
);
132+
const newPage = builder.copyPage(pageId);
133+
134+
set({
135+
session: mergeSession(state.session, { form: builder.form }),
136+
focus: { pattern: newPage },
137+
});
138+
state.addNotification('success', 'Page copied successfully.');
139+
},
140+
124141
addPatternToCompoundField: (patternType, targetPattern) => {
125142
const state = get();
126143
const builder = new BlueprintBuilder(

packages/forms/src/blueprint.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,120 @@ export const movePatternBetweenPages = (
263263
};
264264
};
265265

266+
/**
267+
* Copies a page from a blueprint by creating a duplicate with a new ID.
268+
* This also copies all patterns contained within the page and updates their references.
269+
*
270+
*/
271+
export const copyPage = (
272+
bp: Blueprint,
273+
pageId: PatternId
274+
): { bp: Blueprint; pattern: PagePattern } => {
275+
const pagePattern = bp.patterns[pageId] as PagePattern;
276+
if (!pagePattern || pagePattern.type !== 'page') {
277+
throw new Error(`Pattern with id ${pageId} is not a page.`);
278+
}
279+
280+
const newPageId = generatePatternId();
281+
const timestamp = new Date().toLocaleString();
282+
283+
const newPage: PagePattern = {
284+
...pagePattern,
285+
id: newPageId,
286+
data: {
287+
...pagePattern.data,
288+
title: `${pagePattern.data.title} Copy - ${timestamp}`,
289+
patterns: [],
290+
},
291+
};
292+
293+
let updatedBp: Blueprint = {
294+
...bp,
295+
patterns: {
296+
...bp.patterns,
297+
[newPageId]: newPage,
298+
},
299+
};
300+
301+
const idMap = new Map<PatternId, PatternId>();
302+
303+
const copyPatternAndChildren = (
304+
currentBp: Blueprint,
305+
patternId: PatternId
306+
): { bp: Blueprint; newId: PatternId } => {
307+
const originalPattern = currentBp.patterns[patternId];
308+
if (!originalPattern) {
309+
throw new Error(`Pattern with id ${patternId} not found`);
310+
}
311+
312+
if (idMap.has(patternId)) {
313+
return { bp: currentBp, newId: idMap.get(patternId) as PatternId };
314+
}
315+
316+
const newId = generatePatternId();
317+
idMap.set(patternId, newId);
318+
319+
const newPattern: Pattern = {
320+
...originalPattern,
321+
id: newId,
322+
data: { ...originalPattern.data },
323+
};
324+
325+
let resultBp = {
326+
...currentBp,
327+
patterns: {
328+
...currentBp.patterns,
329+
[newId]: newPattern,
330+
},
331+
};
332+
333+
if (
334+
(newPattern.type === 'fieldset' || newPattern.type === 'repeater') &&
335+
Array.isArray(originalPattern.data.patterns)
336+
) {
337+
const newChildren: PatternId[] = [];
338+
339+
for (const childId of originalPattern.data.patterns) {
340+
const { bp: updatedBp, newId: newChildId } = copyPatternAndChildren(
341+
resultBp,
342+
childId
343+
);
344+
resultBp = updatedBp;
345+
newChildren.push(newChildId);
346+
}
347+
resultBp.patterns[newId].data.patterns = newChildren;
348+
}
349+
return { bp: resultBp, newId };
350+
};
351+
352+
for (const patternId of pagePattern.data.patterns) {
353+
if (bp.patterns[patternId]) {
354+
const { bp: newState } = copyPatternAndChildren(updatedBp, patternId);
355+
updatedBp = newState;
356+
}
357+
}
358+
359+
newPage.data.patterns = pagePattern.data.patterns.map(
360+
id => idMap.get(id) || id
361+
);
362+
updatedBp.patterns[newPageId] = newPage;
363+
364+
const pageSet = updatedBp.patterns[updatedBp.root] as PageSetPattern;
365+
if (pageSet.type === 'page-set') {
366+
updatedBp.patterns[pageSet.id] = {
367+
...pageSet,
368+
data: {
369+
pages: [...pageSet.data.pages, newPageId],
370+
},
371+
} as PageSetPattern;
372+
}
373+
374+
return {
375+
bp: updatedBp,
376+
pattern: updatedBp.patterns[newPageId] as PagePattern,
377+
};
378+
};
379+
266380
/**
267381
* Copies a pattern from a blueprint by creating a duplicate with a new ID.
268382
*

packages/forms/src/builder/builder.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,55 @@ describe('form builder', () => {
569569
});
570570
});
571571

572+
it('copy page with all its patterns', () => {
573+
const initial = createTwoPageThreePatternTestForm();
574+
const builder = new BlueprintBuilder(defaultFormConfig, initial);
575+
const sourcePage = getPattern<PagePattern>(initial, 'page-1');
576+
const newPage = builder.copyPage(sourcePage.id);
577+
578+
expect(newPage.type).toEqual('page');
579+
expect(newPage.id).not.toEqual(sourcePage.id);
580+
expect(newPage.data.title).toMatch(
581+
/Page 1 Copy - \d{1,2}\/\d{1,2}\/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M/
582+
);
583+
584+
const pageSet = builder.form.patterns[builder.form.root] as PageSetPattern;
585+
expect(pageSet.data.pages.length).toEqual(3);
586+
expect(pageSet.data.pages).toContain(newPage.id);
587+
588+
expect(newPage.data.patterns.length).toEqual(
589+
sourcePage.data.patterns.length
590+
);
591+
592+
expect(newPage.data.patterns).not.toEqual(sourcePage.data.patterns);
593+
594+
for (let i = 0; i < newPage.data.patterns.length; i++) {
595+
const originalPatternId = sourcePage.data.patterns[i];
596+
const newPatternId = newPage.data.patterns[i];
597+
const originalPattern = initial.patterns[originalPatternId];
598+
const newPattern = builder.form.patterns[newPatternId];
599+
600+
expect(newPattern.type).toEqual(originalPattern.type);
601+
602+
expect(newPattern.id).not.toEqual(originalPattern.id);
603+
604+
if (newPattern.type === 'input') {
605+
expect((newPattern as InputPattern).data.label).toMatch(
606+
/Pattern \d{1}/
607+
);
608+
expect((newPattern as InputPattern).data.required).toEqual(
609+
(originalPattern as InputPattern).data.required
610+
);
611+
}
612+
}
613+
614+
Object.keys(initial.patterns).forEach(patternId => {
615+
expect(builder.form.patterns[patternId]).toBeDefined();
616+
});
617+
618+
expect(Object.keys(builder.form.patterns).length).toEqual(9);
619+
});
620+
572621
it('removePattern removes pattern and sequence reference', () => {
573622
const initial = createTestBlueprint();
574623
const builder = new BlueprintBuilder(defaultFormConfig, initial);

packages/forms/src/builder/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
addPatternToPage,
66
addPatternToRepeater,
77
copyPattern,
8+
copyPage,
89
createOnePageBlueprint,
910
movePatternBetweenPages,
1011
removePatternFromBlueprint,
@@ -108,8 +109,17 @@ export class BlueprintBuilder {
108109
return pattern;
109110
}
110111

112+
copyPage(pageId: PatternId) {
113+
const root = this.form.patterns[this.form.root] as PageSetPattern;
114+
if (root.type !== 'page-set') {
115+
throw new Error('expected root to be a page-set');
116+
}
117+
const results = copyPage(this.form, pageId);
118+
this.bp = results.bp;
119+
return results.pattern;
120+
}
121+
111122
copyPattern(parentPatternId: PatternId, patternId: PatternId) {
112-
const pattern = getPattern(this.form, patternId);
113123
const root = this.form.patterns[this.form.root] as PageSetPattern;
114124
if (root.type !== 'page-set') {
115125
throw new Error('expected root to be a page-set');

0 commit comments

Comments
 (0)