From 0204b20918dd9265ca01fd0e103b546b73e2702c Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 5 Nov 2026 17:13:28 +0200 Subject: [PATCH] fix(custom-step): fixed bug with radio button --- .../custom-step/custom-step.component.html | 133 +++--- .../custom-step/custom-step.component.spec.ts | 377 ++++++++++-------- .../custom-step/custom-step.component.ts | 63 +-- 3 files changed, 295 insertions(+), 278 deletions(-) diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index f161452d6..022d7d005 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -1,25 +1,23 @@
- @if (currentPage()) { -
-

{{ currentPage().title }}

+ @let page = currentPage(); - @let helpText = currentPage().helpText; + @if (page) { +
+

{{ page.title }}

- @if (helpText) { - + @if (page.helpText) { + }
- @if (currentPage().description?.length) { -

{{ currentPage().description }}

+ @if (page.description?.length) { +

{{ page.description }}

} - @let questions = currentPage().questions || []; - - + - @if (currentPage().sections?.length) { - @for (section of currentPage().sections; track section.id) { + @if (page.sections?.length) { + @for (section of page.sections; track section.id) {

{{ section.title }}

@@ -39,8 +37,8 @@

{{ section.title }}

@for (q of questions; track q.id) { -
-
+ + + @if ( + stepForm.controls[controlName].errors?.['required'] && + (stepForm.controls[controlName].touched || stepForm.controls[controlName].dirty) + ) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + } }
- - - + + +
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index 409a5d1b3..b1c3b39f9 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -10,12 +10,14 @@ import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; +import { FILE_COUNT_ATTACHMENTS_LIMIT } from '@osf/shared/constants/files-limits.const'; import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FilePayloadJsonApi } from '@osf/shared/models/files/file-payload-json-api.model'; +import { PageSchema, Question } from '@osf/shared/models/registration/page-schema.model'; import { ToastService } from '@osf/shared/services/toast.service'; -import { FileModel } from '@shared/models/files/file.model'; -import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.model'; -import { PageSchema } from '@shared/models/registration/page-schema.model'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; import { MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA } from '@testing/mocks/registries.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; @@ -46,6 +48,13 @@ interface SetupOverrides extends BaseSetupOverrides { } describe('CustomStepComponent', () => { + const filePayload: FilePayloadJsonApi = { + file_id: 'f1', + file_name: 'doc.pdf', + file_urls: { html: '', download: '' }, + file_hashes: { sha256: '' }, + }; + function createPage( questions: PageSchema['questions'] = [], sections: PageSchema['sections'] = undefined @@ -53,8 +62,19 @@ describe('CustomStepComponent', () => { return { id: 'p', title: 'P', questions, sections }; } + function createQuestion(overrides: Partial & { responseKey: string; fieldType: FieldType }): Question { + return { + id: overrides.responseKey, + displayText: '', + required: false, + ...overrides, + }; + } + function setup(overrides: SetupOverrides = {}) { - const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { step: 1 }); + const routeBuilder = ActivatedRouteMockBuilder.create().withParams( + overrides.routeParams ?? { step: '1', id: 'draft-1' } + ); if (overrides.hasParent === false) { routeBuilder.withNoParent(); } @@ -94,59 +114,44 @@ describe('CustomStepComponent', () => { return { component, fixture, store, routeBuilder, mockRouter, toastMock }; } - it('should create', () => { - const { component } = setup(); - expect(component).toBeTruthy(); - }); - - it('should emit back on first step', () => { - const { component } = setup(); + it('should emit back on first step and navigate to the previous step later', () => { + const { component, mockRouter } = setup(); const backSpy = vi.spyOn(component.back, 'emit'); + component.goBack(); expect(backSpy).toHaveBeenCalled(); - }); - it('should navigate to previous step on step > 1', () => { - const { component, mockRouter } = setup(); component.step.set(2); component.goBack(); expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 1], { relativeTo: expect.anything() }); }); - it('should navigate to next step within pages', () => { + it('should navigate to the next step or emit next on the last step', () => { const { component, mockRouter } = setup({ pages: [MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE] }); + const nextSpy = vi.spyOn(component.next, 'emit'); + component.goNext(); expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 2], { relativeTo: expect.anything() }); - }); - it('should emit next on last step', () => { - const { component } = setup(); - const nextSpy = vi.spyOn(component.next, 'emit'); - component.step.set(1); + component.step.set(2); component.goNext(); expect(nextSpy).toHaveBeenCalled(); }); - it('should dispatch updateStepState on ngOnDestroy', () => { - const { component, store } = setup(); - (store.dispatch as Mock).mockClear(); - component.ngOnDestroy(); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); - }); - - it('should emit updateAction and dispatch setUpdatedFields when fields changed', () => { + it('should emit updateAction and SetUpdatedFields when fields changed on destroy', () => { const { component, store } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); - component['stepForm'].get('field1')?.setValue('changed'); + component.stepForm.get('field1')?.setValue('changed'); (store.dispatch as Mock).mockClear(); component.ngOnDestroy(); - expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ field1: 'changed' })); expect(store.dispatch).toHaveBeenCalledWith(new SetUpdatedFields({ field1: 'changed' })); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('1', false, true)); }); - it('should not emit updateAction when no fields changed', () => { + it('should dispatch step state without updateAction when nothing changed', () => { const { component, store } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); (store.dispatch as Mock).mockClear(); @@ -155,9 +160,10 @@ describe('CustomStepComponent', () => { expect(emitSpy).not.toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetUpdatedFields)); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('1', false, true)); }); - it('should skip saveStepState when form has no controls', () => { + it('should skip save when the form has no controls', () => { const { component, store } = setup(); component.stepForm = new FormGroup({}); (store.dispatch as Mock).mockClear(); @@ -167,198 +173,219 @@ describe('CustomStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update file upload description based on isProjectCreationDisabled', () => { + it('should not treat attached file display names as field changes', () => { const { component } = setup({ - selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], - }); - expect(component.fileUploadDescription()).toBe('shared.files.descriptionNoProject'); - - const { component: component2 } = setup({ - selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: false }], + pages: [createPage([createQuestion({ responseKey: 'files', fieldType: FieldType.File })])], + stepsData: { files: [filePayload] }, }); - expect(component2.fileUploadDescription()).toBe('shared.files.description'); - }); - - it('should attach file and emit updateAction', () => { - const { component } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); - const mockFile = { - id: 'new-file', - name: 'new.txt', - links: { html: 'http://html', download: 'http://dl' }, - extra: { hashes: { sha256: 'abc' } }, - } as FileModel; - component.onAttachFile(mockFile, 'field1'); - - expect(component.attachedFiles['field1'].length).toBe(1); - expect(emitSpy).toHaveBeenCalled(); - expect(emitSpy.mock.calls[0][0]['field1'][0].file_id).toBe('new-file'); - }); - - it('should not attach duplicate file', () => { - const { component } = setup(); - component.attachedFiles['field1'] = [{ file_id: 'file-1', name: 'existing.txt' }]; - const emitSpy = vi.spyOn(component.updateAction, 'emit'); - - component.onAttachFile({ id: 'file-1' } as FileModel, 'field1'); + component.ngOnDestroy(); - expect(component.attachedFiles['field1'].length).toBe(1); + expect(component.attachedFiles['files'][0].name).toBe('doc.pdf'); expect(emitSpy).not.toHaveBeenCalled(); }); - it('should show warning when attachment limit reached', () => { - const { component, toastMock } = setup(); - component.attachedFiles['field1'] = Array.from({ length: 5 }, (_, i) => ({ file_id: `f-${i}`, name: `f-${i}` })); + it('should save state and update step and draftId on route param change', () => { + const { component, store, routeBuilder } = setup({ routeParams: { step: '1', id: 'draft-1' } }); + (store.dispatch as Mock).mockClear(); - const mockFile = { - id: 'new', - name: 'new.txt', - links: { html: '', download: '' }, - extra: { hashes: { sha256: '', md5: '' } }, - } as FileModel; - component.onAttachFile(mockFile, 'field1'); + routeBuilder.withParams({ step: '2', id: 'draft-2' }); - expect(toastMock.showWarn).toHaveBeenCalledWith('shared.files.limitText'); - expect(component.attachedFiles['field1'].length).toBe(5); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + expect(component.step()).toBe(2); + expect(component.draftId()).toBe('draft-2'); }); - it('should remove file and emit updateAction', () => { - const { component } = setup(); - const emitSpy = vi.spyOn(component.updateAction, 'emit'); - component.attachedFiles['field1'] = [ - { file_id: 'f1', name: 'a' }, - { file_id: 'f2', name: 'b' }, - ]; - - component.removeFromAttachedFiles('f1', 'field1'); + it('should initialize page and section controls and skip unknown types', () => { + const { component } = setup({ + pages: [ + createPage( + [ + createQuestion({ responseKey: 'cbField', fieldType: FieldType.Checkbox, required: true }), + createQuestion({ responseKey: 'radioField', fieldType: FieldType.Radio, required: true }), + createQuestion({ responseKey: 'fileField', fieldType: FieldType.File }), + createQuestion({ responseKey: 'unknownField', fieldType: 'unknown' as FieldType }), + ], + [ + { + id: 's1', + title: 'S', + questions: [createQuestion({ responseKey: 'secField', fieldType: FieldType.Text })], + }, + ] + ), + ], + stepsData: { fileField: [filePayload], secField: 'val' }, + }); - expect(component.attachedFiles['field1'].length).toBe(1); - expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); - expect(emitSpy).toHaveBeenCalled(); + expect(component.stepForm.get('cbField')?.value).toEqual([]); + expect(component.stepForm.get('radioField')?.valid).toBe(false); + expect(component.attachedFiles['fileField'][0].name).toBe('doc.pdf'); + expect(component.stepForm.get('unknownField')).toBeNull(); + expect(component.stepForm.get('secField')?.value).toBe('val'); }); - it('should open file preview in new tab when draftId and file guid exist', () => { - const urlTree = {} as UrlTree; - const { component, mockRouter } = setup({ - routeParams: { step: '1', id: 'draft-1' }, - urlTree, - serializedUrl: '/draft-1/files/file-guid/preview', + it('should mark the form touched when the current step is invalid', () => { + const { component } = setup({ + stepsState: { 1: { invalid: true, touched: true } }, }); - const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - - component.onOpenFile({ guid: 'file-guid' } as FileModel); - - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['draft-1', 'files', 'file-guid', 'preview']); - expect(mockRouter.serializeUrl).toHaveBeenCalledWith(urlTree); - expect(openSpy).toHaveBeenCalledWith('/draft-1/files/file-guid/preview', '_blank'); + expect(component.stepForm.get('field1')?.touched).toBe(true); }); - it('should not open file preview when file guid is missing', () => { - const { component } = setup({ routeParams: { step: '1', id: 'draft-1' } }); - const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - - component.onOpenFile({ guid: null } as FileModel); - - expect(openSpy).not.toHaveBeenCalled(); - }); + it('should reset attachedFiles when navigating to another page', async () => { + const { component, fixture, routeBuilder } = setup({ + pages: [ + createPage([createQuestion({ responseKey: 'files', fieldType: FieldType.File })]), + createPage([createQuestion({ responseKey: 'title', fieldType: FieldType.Text })]), + ], + stepsData: { files: [filePayload], title: '' }, + routeParams: { step: '1', id: 'draft-1' }, + }); - it('should skip non-existent questionKey', () => { - const { component } = setup(); - const emitSpy = vi.spyOn(component.updateAction, 'emit'); - component.removeFromAttachedFiles('f1', 'nonexistent'); - expect(emitSpy).not.toHaveBeenCalled(); - }); + expect(component.attachedFiles['files']?.length).toBe(1); - it('should save step state and update step on route param change', () => { - const { component, store, routeBuilder } = setup(); - (store.dispatch as Mock).mockClear(); - routeBuilder.withParams({ step: 2 }); + routeBuilder.withParams({ step: '2', id: 'draft-1' }); + await fixture.whenStable(); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); - expect(component.step()).toBe(2); + expect(component.attachedFiles['files']).toBeUndefined(); + expect(component.stepForm.get('title')).toBeDefined(); }); - it('should mark form touched when stepsState has invalid for current step', () => { + it('should use the no-project file upload description when project creation is disabled', () => { const { component } = setup({ - stepsState: { 1: { invalid: true, touched: true } }, + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); - expect(component['stepForm'].get('field1')?.touched).toBe(true); + expect(component.fileUploadDescription()).toBe('shared.files.descriptionNoProject'); }); - it('should initialize checkbox control with empty array default', () => { + it('should attach a file and emit a mapped form payload', () => { const { component } = setup({ pages: [ createPage([ - { id: 'q', displayText: '', responseKey: 'cbField', fieldType: FieldType.Checkbox, required: true }, + createQuestion({ responseKey: 'title', fieldType: FieldType.Text }), + createQuestion({ responseKey: 'files', fieldType: FieldType.File }), ]), ], - stepsData: {}, + stepsData: { title: 'a', files: [] }, + }); + const emitSpy = vi.spyOn(component.updateAction, 'emit'); + const mockFile = FileModelMock.simple({ + id: 'new-file', + name: 'new.txt', + links: { ...FileModelMock.simple().links, html: 'http://html', download: 'http://dl' }, + extra: { hashes: { sha256: 'abc', md5: '' }, downloads: 0 }, }); - expect(component['stepForm'].get('cbField')?.value).toEqual([]); - }); - it('should initialize radio control with required validator', () => { - const { component } = setup({ - pages: [ - createPage([ - { id: 'q', displayText: '', responseKey: 'radioField', fieldType: FieldType.Radio, required: true }, - ]), + component.onAttachFile(mockFile, 'files'); + + expect(component.attachedFiles['files'].length).toBe(1); + expect(emitSpy).toHaveBeenCalledWith({ + title: 'a', + files: [ + { + file_id: 'new-file', + file_name: 'new.txt', + file_urls: { html: 'http://html', download: 'http://dl' }, + file_hashes: { sha256: 'abc' }, + }, ], - stepsData: {}, }); - expect(component['stepForm'].get('radioField')?.valid).toBe(false); }); - it('should initialize file control and populate attachedFiles', () => { - const files: FilePayloadJsonApi[] = [ - { file_id: 'f1', file_name: 'doc.pdf', file_urls: { html: '', download: '' }, file_hashes: { sha256: '' } }, - ]; + it('should not attach a duplicate file', () => { const { component } = setup({ - pages: [ - createPage([ - { id: 'q', displayText: '', responseKey: 'fileField', fieldType: FieldType.File, required: false }, - ]), - ], - stepsData: { fileField: files }, + pages: [createPage([createQuestion({ responseKey: 'files', fieldType: FieldType.File })])], + stepsData: { files: [] }, }); + component.attachedFiles['files'] = [{ file_id: 'file-1', name: 'existing.txt' }]; + const emitSpy = vi.spyOn(component.updateAction, 'emit'); - expect(component.attachedFiles['fileField'].length).toBe(1); - expect(component.attachedFiles['fileField'][0].name).toBe('doc.pdf'); + component.onAttachFile(FileModelMock.simple({ id: 'file-1' }), 'files'); + + expect(component.attachedFiles['files'].length).toBe(1); + expect(emitSpy).not.toHaveBeenCalled(); }); - it('should skip unknown field types', () => { - const { component } = setup({ - pages: [ - createPage([ - { id: 'q', displayText: '', responseKey: 'unknownField', fieldType: 'unknown' as FieldType, required: false }, - ]), - ], - stepsData: {}, + it('should warn when the attachment limit is reached', () => { + const { component, toastMock } = setup({ + pages: [createPage([createQuestion({ responseKey: 'files', fieldType: FieldType.File })])], + stepsData: { files: [] }, }); - expect(component['stepForm'].get('unknownField')).toBeNull(); + component.attachedFiles['files'] = Array.from({ length: FILE_COUNT_ATTACHMENTS_LIMIT }, (_, i) => ({ + file_id: `f-${i}`, + name: `f-${i}`, + })); + + component.onAttachFile(FileModelMock.simple({ id: 'new' }), 'files'); + + expect(toastMock.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + expect(component.attachedFiles['files'].length).toBe(FILE_COUNT_ATTACHMENTS_LIMIT); }); - it('should include section questions', () => { + it('should remove a file and emit the mapped question payload', () => { const { component } = setup({ - pages: [ - createPage( - [], - [ - { - id: 's1', - title: 'S', - questions: [ - { id: 'q', displayText: '', responseKey: 'secField', fieldType: FieldType.Text, required: false }, - ], - }, - ] - ), + pages: [createPage([createQuestion({ responseKey: 'files', fieldType: FieldType.File })])], + stepsData: { files: [] }, + }); + const emitSpy = vi.spyOn(component.updateAction, 'emit'); + component.attachedFiles['files'] = [ + { ...filePayload, name: 'a' }, + { + file_id: 'f2', + file_name: 'b.pdf', + file_urls: { html: '', download: '' }, + file_hashes: { sha256: '' }, + name: 'b', + }, + ]; + + component.removeFromAttachedFiles('f1', 'files'); + + expect(component.attachedFiles['files']).toEqual([expect.objectContaining({ file_id: 'f2', name: 'b' })]); + expect(emitSpy).toHaveBeenCalledWith({ + files: [ + { + file_id: 'f2', + file_name: 'b.pdf', + file_urls: { html: '', download: '' }, + file_hashes: { sha256: '' }, + }, ], - stepsData: { secField: 'val' }, }); + }); + + it('should skip remove when the question key is missing', () => { + const { component } = setup(); + const emitSpy = vi.spyOn(component.updateAction, 'emit'); + + component.removeFromAttachedFiles('f1', 'nonexistent'); - expect(component['stepForm'].get('secField')).toBeDefined(); - expect(component['stepForm'].get('secField')?.value).toBe('val'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should open file preview when draftId and guid exist', () => { + const urlTree = {} as UrlTree; + const { component, mockRouter } = setup({ + routeParams: { step: '1', id: 'draft-1' }, + urlTree, + serializedUrl: '/draft-1/files/file-guid/preview', + }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + component.onOpenFile({ guid: 'file-guid' } as FileModel); + + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['draft-1', 'files', 'file-guid', 'preview']); + expect(mockRouter.serializeUrl).toHaveBeenCalledWith(urlTree); + expect(openSpy).toHaveBeenCalledWith('/draft-1/files/file-guid/preview', '_blank'); + }); + + it('should not open file preview when guid is missing', () => { + const { component } = setup({ routeParams: { step: '1', id: 'draft-1' } }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + component.onOpenFile({ guid: null } as FileModel); + + expect(openSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index ce1d26fc7..bc4a933df 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -29,17 +29,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { UserSelectors } from '@osf/core/store/user/user.selectors'; +import { UserSelectors } from '@osf/core/store/user'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { FILE_COUNT_ATTACHMENTS_LIMIT } from '@osf/shared/constants/files-limits.const'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { FieldType } from '@osf/shared/enums/field-type.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FilePayloadJsonApi } from '@osf/shared/models/files/file-payload-json-api.model'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { ToastService } from '@osf/shared/services/toast.service'; -import { FileModel } from '@shared/models/files/file.model'; -import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.model'; -import { PageSchema } from '@shared/models/registration/page-schema.model'; import { FilesMapper } from '../../mappers/files.mapper'; import { AttachedFile } from '../../models/attached-file.model'; @@ -69,16 +69,14 @@ import { FilesControlComponent } from '../files-control/files-control.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export class CustomStepComponent implements OnDestroy { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stepsData = input.required>(); - filesLink = input.required(); - projectId = input.required(); - filesViewOnly = input(false); + readonly stepsData = input.required>(); + readonly filesLink = input.required(); + readonly projectId = input.required(); + readonly filesViewOnly = input(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateAction = output>(); - back = output(); - next = output(); + readonly updateAction = output>(); + readonly back = output(); + readonly next = output(); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -98,9 +96,9 @@ export class CustomStepComponent implements OnDestroy { readonly FieldType = FieldType; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - step = signal(this.route.snapshot.params['step']); - draftId = signal(this.route.snapshot.params['id']); - currentPage = computed(() => this.pages()[this.step() - 1]); + readonly step = signal(+this.route.snapshot.params['step']); + readonly draftId = signal(this.route.snapshot.params['id'] as string); + readonly currentPage = computed(() => this.pages()[this.step() - 1]); readonly fileUploadDescription = computed(() => { if (this.projectCreationDisabled()) { return 'shared.files.descriptionNoProject'; @@ -134,13 +132,7 @@ export class CustomStepComponent implements OnDestroy { this.attachedFiles[questionKey] = [...this.attachedFiles[questionKey], file]; this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); - - const otherFormValues = { ...this.stepForm.value }; - delete otherFormValues[questionKey]; - this.updateAction.emit({ - [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), - ...otherFormValues, - }); + this.updateAction.emit(this.getFormPayload()); } onOpenFile(file: FileModel): void { @@ -184,6 +176,7 @@ export class CustomStepComponent implements OnDestroy { this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.saveStepState(); this.step.set(+params['step']); + this.draftId.set(params['id']); }); } @@ -198,6 +191,7 @@ export class CustomStepComponent implements OnDestroy { private initStepForm(page: PageSchema): void { this.stepForm = this.fb.group({}); + this.attachedFiles = {}; const questions = [ ...(page.questions || []), ...(page.sections?.flatMap((section) => section.questions ?? []) ?? []), @@ -209,8 +203,8 @@ export class CustomStepComponent implements OnDestroy { if (!control) return; if (q.fieldType === FieldType.File) { - this.attachedFiles[controlName] = - this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; + const files = (this.stepsData()[controlName] as FilePayloadJsonApi[] | undefined) || []; + this.attachedFiles[controlName] = files.map((file) => ({ ...file, name: file.file_name })); } this.stepForm.addControl(controlName, control); @@ -253,14 +247,27 @@ export class CustomStepComponent implements OnDestroy { return; } - const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); + const formPayload = this.getFormPayload(); + const changedFields = findChangedFields(formPayload, this.stepsData()); if (Object.keys(changedFields).length > 0) { this.actions.setUpdatedFields(changedFields); - this.updateAction.emit(this.stepForm.value); + this.updateAction.emit(formPayload); } this.stepForm.markAllAsTouched(); - this.actions.updateStepState(this.step(), this.stepForm.invalid, true); + this.actions.updateStepState(String(this.step()), this.stepForm.invalid, true); + } + + private getFormPayload(): Record { + const values: Record = { ...this.stepForm.value }; + + for (const key of Object.keys(this.attachedFiles)) { + if (Object.prototype.hasOwnProperty.call(values, key)) { + values[key] = this.mapFilesToPayload(this.attachedFiles[key] ?? []); + } + } + + return values; } private mapFilesToPayload(files: AttachedFile[]): FilePayloadJsonApi[] {