diff --git a/apps/spotlight/src/env.d.ts b/apps/spotlight/src/env.d.ts index f964fe0c..acef35f1 100644 --- a/apps/spotlight/src/env.d.ts +++ b/apps/spotlight/src/env.d.ts @@ -1 +1,2 @@ +/// /// diff --git a/e2e/src/create.spec.ts b/e2e/src/create.spec.ts index 21a96c9f..a5c2997c 100644 --- a/e2e/src/create.spec.ts +++ b/e2e/src/create.spec.ts @@ -9,7 +9,7 @@ const createNewForm = async (page: Page) => { } const addQuestions = async (page: Page) => { - const menuButton = page.getByRole('button', { name: 'Question' }); + const menuButton = page.getByRole('button', { name: 'Question', exact: true }); await menuButton.click(); await page.getByRole('button', { name: 'Short Answer' }).click(); await menuButton.click(); diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index e5c396e6..4c7e02c5 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -282,12 +282,9 @@ const AddPatternDropdownContent = ({ className={`${styles.dropdownMenu} usa-list usa-list--unstyled position-absolute bg-white z-100 shadow-3 text-left`} > {availablePatterns.map(([patternType, pattern], index) => ( -
  • +
  • +

    + {dropdownOpen && ( +
    +
    + + +
    +
    + + +
    +

    + +

    +
    + )} + + ); +}; + +export default MovePatternDropdown; diff --git a/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx b/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx index dcc63fe7..d67e5cdb 100644 --- a/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx +++ b/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx @@ -1,7 +1,9 @@ -import React, { PropsWithChildren, ReactElement } from 'react'; +import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; import classNames from 'classnames'; import { useFormManagerStore } from '../../../store'; +import MovePatternDropdown from './MovePatternDropdown'; +import styles from '../../formEditStyles.module.css'; type PatternEditActionsProps = PropsWithChildren<{ children?: ReactElement; @@ -13,16 +15,42 @@ export const PatternEditActions = ({ children }: PatternEditActionsProps) => { const { deleteSelectedPattern } = useFormManagerStore(state => ({ deleteSelectedPattern: state.deleteSelectedPattern, })); + const focusPatternType = useFormManagerStore( + state => state.focus?.pattern.type + ); + const patterns = useFormManagerStore(state => + Object.values(state.session.form.patterns) + ); + const focusPatternId = useFormManagerStore(state => state.focus?.pattern.id); + const isPatternInFieldset = useMemo(() => { + if (!focusPatternId) return false; + return patterns.some( + p => p.type === 'fieldset' && p.data.patterns.includes(focusPatternId) + ); + }, [focusPatternId, patterns]); + + const isFieldset = focusPatternType === 'fieldset'; + const isPagePattern = focusPatternType === 'page'; return ( - <> -
    - +
    + {!isPatternInFieldset && !isPagePattern && ( + + )} + - - - {children ? ( - - {children} - + {children} ) : null}
    - +
    + +
    +
    ); }; diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index fbec8d0d..d6c78511 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -89,15 +89,6 @@ background: none; } -@media (max-width: 40em) { - .dropdownMenu { - bottom: 4.5rem; - max-height: 40vh; - overflow: auto; - left: 32px; - } -} - .dottedLine { display: flex; align-items: center; @@ -118,3 +109,81 @@ .dottedLine::after { margin: 0 0 0 1em; } + +/* Move to Page */ + +.moveToPage, +.questionPosition { + max-width: 21rem; +} + +.draggableListWrapper:has(.patternActionWrapper .dropDown select:focus), +.draggableListWrapper:has(.patternActionWrapper .dropDown:focus ) { + outline: 0; +} + +.patternActionWrapper .dropdownMenu div { + padding: 8px; + cursor: pointer; +} + +.patternActionWrapper .dropdownMenu div:hover { + background: #f0f0f0; +} + +.patternActionWrapper .dropDown { + position: absolute; + top: 3rem; + left: 0; + background-color: white; + border: 1px solid #ccc; + z-index: 100; + width: 16rem; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.patternActionWrapper .dropDown select { + max-width: 9.375rem; +} + +.patternActionWrapper .dropDown button { + width: 9.375rem; +} + +.patternActionButtons { + max-width: 15.625rem; +} + +.movePatternButton { + color: #005ea2; +} + +.movePatternButton:focus { + outline: 0.25rem solid red; +} + +.movePatternButton span { + font-size: 1.25rem; +} + +.movePatternButton svg { + font-size: 1.5rem; +} + +@media (min-width: 64.5em) { + + + .moveToPage, + .questionPosition { + min-width: 18.75rem; + } +} + +@media (max-width: 40em) { + .dropdownMenu { + bottom: 4.5rem; + max-height: 40vh; + overflow: auto; + left: 32px; + } +} \ No newline at end of file diff --git a/packages/design/src/FormManager/FormEdit/store.ts b/packages/design/src/FormManager/FormEdit/store.ts index c180ad31..afbde481 100644 --- a/packages/design/src/FormManager/FormEdit/store.ts +++ b/packages/design/src/FormManager/FormEdit/store.ts @@ -29,6 +29,12 @@ export type FormEditSlice = { clearFocus: () => void; deletePattern: (id: PatternId) => void; deleteSelectedPattern: () => void; + movePattern: ( + sourcePage: PatternId, + targetPage: PatternId, + patternId: PatternId, + position: string + ) => void; setFocus: (patternId: PatternId) => boolean; setRouteParams: (routeParams: string) => void; updatePattern: (data: Pattern) => void; @@ -69,12 +75,33 @@ export const createFormEditSlice = ); const page = getSessionPage(state.session); const newPattern = builder.addPatternToPage(patternType, page); + set({ session: mergeSession(state.session, { form: builder.form }), focus: { pattern: newPattern }, }); state.addNotification('success', 'Element added successfully.'); }, + movePattern: (sourcePage, targetPage, patternId, position) => { + const state = get(); + const builder = new BlueprintBuilder( + state.context.config, + state.session.form + ); + + const movePatternBetweenPages = builder.movePatternBetweenPages( + sourcePage, + targetPage, + patternId, + position + ); + + set({ + session: mergeSession(state.session, { form: builder.form }), + focus: { pattern: movePatternBetweenPages }, + }); + state.addNotification('success', 'Element moved successfully.'); + }, addPatternToFieldset: (patternType, targetPattern) => { const state = get(); const builder = new BlueprintBuilder( diff --git a/packages/forms/src/builder/builder.test.ts b/packages/forms/src/builder/builder.test.ts index 96f4037b..125a1abc 100644 --- a/packages/forms/src/builder/builder.test.ts +++ b/packages/forms/src/builder/builder.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BlueprintBuilder } from '.'; -import { createForm, getPattern } from '..'; +import { createForm, getPattern, Pattern } from '..'; import { defaultFormConfig } from '../patterns'; import { type InputPattern } from '../patterns/input'; import { PageSetPattern } from '../patterns/page-set/config'; @@ -22,12 +22,219 @@ describe('form builder', () => { expect(builder.form.patterns[newPattern.id]).toEqual(newPattern); const oldPage = getPattern(initial, 'page-1'); const newPage = getPattern(builder.form, 'page-1'); + expect(newPage.data).toEqual({ ...oldPage.data, patterns: [...oldPage.data.patterns, newPattern.id], }); }); + it('movePattern on the currentpage', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-1'); + builder.movePatternBetweenPages( + oldPage.id, + newPage.id, + pattern.id, + 'bottom' + ); + + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2', 'element-1'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + + it('movePattern to top of a different page', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-2'); + builder.movePatternBetweenPages(oldPage.id, newPage.id, pattern.id, 'top'); + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-1', 'element-3'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + + it('movePattern to bottom of a different page', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-2'); + builder.movePatternBetweenPages( + oldPage.id, + newPage.id, + pattern.id, + 'bottom' + ); + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3', 'element-1'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + it('removePattern removes pattern and sequence reference', () => { const initial = createTestBlueprint(); const builder = new BlueprintBuilder(defaultFormConfig, initial); @@ -108,3 +315,70 @@ export const createTestBlueprint = () => { } ); }; + +export const createTwoPageThreePatternTestForm = () => { + return createForm( + { + title: 'Test form', + description: 'Test description', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-1', 'element-2'], + }, + } satisfies PagePattern, + { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3'], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + ], + } + ); +}; diff --git a/packages/forms/src/builder/index.ts b/packages/forms/src/builder/index.ts index 12c297c5..8bc648b3 100644 --- a/packages/forms/src/builder/index.ts +++ b/packages/forms/src/builder/index.ts @@ -17,9 +17,11 @@ import { updatePatternFromFormData, createOnePageBlueprint, addPatternToFieldset, + movePatternBetweenPages, } from '..'; import { type PageSetPattern } from '../patterns/page-set/config'; import { FieldsetPattern } from '../patterns/fieldset'; +import { PagePattern } from '../patterns/page/config'; export class BlueprintBuilder { bp: Blueprint; @@ -58,6 +60,33 @@ export class BlueprintBuilder { } const pagePatternId = root.data.pages[pageNum]; this.bp = addPatternToPage(this.form, pagePatternId, pattern); + + return pattern; + } + + movePatternBetweenPages( + sourcePageId: PatternId, + targetPageId: PatternId, + patternId: PatternId, + position: string + ) { + const pattern = getPattern(this.form, patternId); + if (!pattern) { + throw new Error(`Pattern with id ${patternId} not found.`); + } + const root = this.form.patterns[this.form.root] as PageSetPattern; + if (root.type !== 'page-set') { + throw new Error('expected root to be a page-set'); + } + + this.bp = movePatternBetweenPages( + this.form, + sourcePageId, + targetPageId, + patternId, + position + ); + return pattern; } diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 68edf48e..4af7323f 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -212,6 +212,84 @@ export const addPatternToPage = ( }; }; +export const movePatternBetweenPages = ( + bp: Blueprint, + sourcePageId: PatternId, + targetPageId: PatternId, + patternId: PatternId, + position: string +): Blueprint => { + const sourcePage = bp.patterns[sourcePageId] as PagePattern; + const targetPage = bp.patterns[targetPageId] as PagePattern; + + if (!sourcePage || !targetPage) { + throw new Error('Source or target page not found.'); + } + + if (sourcePage.type !== 'page' || targetPage.type !== 'page') { + throw new Error('Pattern is not a page.'); + } + + let updatedSourcePatterns: PatternId[]; + let updatedTargetPatterns: PatternId[]; + + if (sourcePageId === targetPageId) { + const sourcePagePatterns = sourcePage.data.patterns; + const indexToRemove = sourcePagePatterns.indexOf(patternId); + + if (indexToRemove === -1) { + throw new Error(`Pattern ID ${patternId} not found in the source page.`); + } + + updatedSourcePatterns = [ + ...sourcePagePatterns.slice(0, indexToRemove), + ...sourcePagePatterns.slice(indexToRemove + 1), + ]; + + updatedTargetPatterns = + position === 'top' + ? [patternId, ...updatedSourcePatterns] + : [...updatedSourcePatterns, patternId]; + } else { + const indexToRemove = sourcePage.data.patterns.indexOf(patternId); + + if (indexToRemove === -1) { + throw new Error(`Pattern ID ${patternId} not found in the source page.`); + } + + updatedSourcePatterns = [ + ...sourcePage.data.patterns.slice(0, indexToRemove), + ...sourcePage.data.patterns.slice(indexToRemove + 1), + ]; + + updatedTargetPatterns = + position === 'top' + ? [patternId, ...targetPage.data.patterns] + : [...targetPage.data.patterns, patternId]; + } + + return { + ...bp, + patterns: { + ...bp.patterns, + [sourcePageId]: { + ...sourcePage, + data: { + ...sourcePage.data, + patterns: updatedSourcePatterns, + }, + } satisfies PagePattern, + [targetPageId]: { + ...targetPage, + data: { + ...targetPage.data, + patterns: updatedTargetPatterns, + }, + } satisfies PagePattern, + }, + }; +}; + export const addPatternToFieldset = ( bp: Blueprint, fieldsetPatternId: PatternId, @@ -346,6 +424,7 @@ export const removePatternFromBlueprint = ( }, {} as PatternMap ); + // Remove the pattern itself delete patterns[id]; return {