diff --git a/.gitignore b/.gitignore index 6a8fd526ec8c6c600fd616bca2a7893d2dee5d3e..a8d2e8e3d8b61967e872f5daed2c0fb7e5483d29 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ target/ .attach** .factorypath .vscode* +.nx \ No newline at end of file diff --git a/alfa-client/.nvmrc b/alfa-client/.nvmrc index 87ec8842b158d213e0477ba0129281a484b9d47d..48b14e6b2b56f3819ca134cef59bc09580f44fd6 100644 --- a/alfa-client/.nvmrc +++ b/alfa-client/.nvmrc @@ -1 +1 @@ -18.18.2 +20.14.0 diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html index 103528775105ccb5f6c0cd000052581a9cbe76de..bd8034d8366f83cf4ddbee1e54c271fb932f2c9a 100644 --- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html +++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.html @@ -6,6 +6,7 @@ [variant]="variant" [attr.data-test-id]="(label | convertForDataTest) + '-text-editor'" [required]="required" + [focus]="focus" > <ods-validation-error error diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.spec.ts b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.spec.ts index 5e06ccb099acf25b81bdbd3df74ca21eaa8deedf..90050933f1b1efdcd32a4f69238588c7becfafe0 100644 --- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.spec.ts +++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.spec.ts @@ -37,6 +37,10 @@ describe('TextEditorComponent', () => { expect(element).toBeInstanceOf(HTMLElement); }); + it('should have focus set to false by default', () => { + expect(component.focus).toBeFalsy(); + }); + describe('errors', () => { it('should be hidden', () => { const element: HTMLElement = getElementFromFixture(fixture, errorId); diff --git a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts index 151bc07b6be0d5b8fba125bd58a90ed92cbbf4d3..a1881546fe7309af8c1992f7c17645fe3e692ad4 100644 --- a/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/text-editor/text-editor.component.ts @@ -23,6 +23,7 @@ export class TextEditorComponent extends FormControlEditorAbstractComponent impl @Input() autocomplete: 'off' | 'email' = 'off'; @Input() placeholder: string = ''; @Input() required: boolean = false; + @Input() focus: boolean = false; get variant(): string { return this.issues.length > 0 ? 'error' : 'default'; diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html index a937dde4d2b7600b2a191542cf9aa2731c8a5ef4..73ea744d07206658aafb6af0e95533b8d106960b 100644 --- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html +++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.html @@ -6,6 +6,7 @@ [variant]="variant" [attr.data-test-id]="(label | convertForDataTest) + '-textarea-editor'" [required]="required" + [focus]="focus" > <ods-validation-error error diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.spec.ts b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.spec.ts index 2f73ad92072533f1fd72026ec3adf65b9653d162..4edd71d78ee59fa5c97ed6bf7ec19eff5f335530 100644 --- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.spec.ts +++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.spec.ts @@ -33,6 +33,10 @@ describe('TextareaEditorComponent', () => { expect(element).toBeInstanceOf(HTMLElement); }); + it('should have focus set to false by default', () => { + expect(component.focus).toBeFalsy(); + }); + describe('errors', () => { it('should be hidden', () => { const element: HTMLElement = getElementFromFixture(fixture, errorId); diff --git a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts index f0a749becccd815963681662c81c28cc3f0c3c4a..1514871a5a9e0d6032e96fd0b85cd3064726ff13 100644 --- a/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/textarea-editor/textarea-editor.component.ts @@ -23,6 +23,7 @@ export class TextareaEditorComponent extends FormControlEditorAbstractComponent @Input() placeholder: string; @Input() rows: number = 10; @Input() required: boolean = false; + @Input() focus: boolean = false; get variant(): string { return this.issues.length > 0 ? 'error' : 'default'; diff --git a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.spec.ts b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.spec.ts index 9f0ce2fb81efdf97dc2f74c466046b9447b249b2..082f7135ba559f5e393f7d97311a5624f5d28dea 100644 --- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.spec.ts @@ -1,11 +1,16 @@ +import { convertForDataTest } from '@alfa-client/tech-shared'; +import { getElementFromFixture } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import faker from '@faker-js/faker'; +import { getDataTestIdOf } from '../../../../../tech-shared/test/data-test'; import { TextInputComponent } from './text-input.component'; describe('TextInputComponent', () => { let component: TextInputComponent; let fixture: ComponentFixture<TextInputComponent>; + const label: string = faker.word.noun(); + const dataTestId: string = `${convertForDataTest(label)}-text-input`; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -14,7 +19,7 @@ describe('TextInputComponent', () => { fixture = TestBed.createComponent(TextInputComponent); component = fixture.componentInstance; - component.label = faker.word.noun(); + component.label = label; component.fieldControl = new FormControl(); fixture.detectChanges(); }); @@ -22,4 +27,51 @@ describe('TextInputComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('aria', () => { + it('should have aria-invalid attribute', () => { + component.variant = 'error'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.hasAttribute('aria-invalid')).toBe(true); + }); + + it('should have aria-invalid value false', () => { + component.variant = 'default'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.getAttribute('aria-invalid')).toEqual('false'); + }); + + it('should have aria-invalid value true', () => { + component.variant = 'error'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.getAttribute('aria-invalid')).toEqual('true'); + }); + }); + + describe('focus', () => { + beforeEach(() => { + component.inputElement.nativeElement.focus = jest.fn(); + }); + + it('should focus', () => { + component.focus = true; + + expect(component.inputElement.nativeElement.focus).toHaveBeenCalled(); + }); + + it('should not focus', () => { + component.focus = false; + + expect(component.inputElement.nativeElement.focus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts index ffb625fbac3347909db7779389f1d42cf5bed157..87770f419954a3c32d59424b0aab2a6a6c05a96b 100644 --- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts @@ -1,8 +1,8 @@ -import { convertForDataTest } from '@alfa-client/tech-shared'; +import { convertForDataTest, TechSharedModule } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva, VariantProps } from 'class-variance-authority'; import { ErrorMessageComponent } from '../error-message/error-message.component'; const textInputVariants = cva( @@ -24,12 +24,13 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; @Component({ selector: 'ods-text-input', standalone: true, - imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule], + imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule, TechSharedModule], template: ` <div> <label [for]="id" class="text-md mb-2 block font-medium text-text" - >{{ label }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container></label - > + >{{ label }} + <ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> + </label> <div class="mt-2"> <input type="text" @@ -39,6 +40,9 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; [placeholder]="placeholder" [autocomplete]="autocomplete" [required]="required" + [attr.aria-invalid]="variant === 'error'" + [attr.data-test-id]="(label | convertForDataTest) + '-text-input'" + #inputElement /> </div> <ng-content select="[error]"></ng-content> @@ -46,6 +50,8 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; `, }) export class TextInputComponent { + @ViewChild('inputElement') inputElement: ElementRef; + @Input({ required: true }) label: string; @Input() placeholder: string = ''; @Input() autocomplete: string = 'off'; @@ -53,6 +59,12 @@ export class TextInputComponent { @Input() fieldControl: FormControl; @Input() required: boolean = false; + @Input() set focus(value: boolean) { + if (value && this.inputElement) { + this.inputElement.nativeElement.focus(); + } + } + textInputVariants = textInputVariants; get id(): string { diff --git a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.spec.ts b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.spec.ts index 352e8ef418e6da5cb3fe04af88c96b52eb336853..e73b09cb52e462dfe30ec98f401738ff975a0ec8 100644 --- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.spec.ts @@ -1,11 +1,16 @@ +import { convertForDataTest } from '@alfa-client/tech-shared'; +import { getElementFromFixture } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import faker from '@faker-js/faker'; +import { getDataTestIdOf } from '../../../../../tech-shared/test/data-test'; import { TextareaComponent } from './textarea.component'; describe('TextareaComponent', () => { let component: TextareaComponent; let fixture: ComponentFixture<TextareaComponent>; + const label: string = faker.word.noun(); + const dataTestId: string = `${convertForDataTest(label)}-textarea`; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -14,7 +19,7 @@ describe('TextareaComponent', () => { fixture = TestBed.createComponent(TextareaComponent); component = fixture.componentInstance; - component.label = faker.word.noun(); + component.label = label; component.fieldControl = new FormControl(); fixture.detectChanges(); }); @@ -22,4 +27,51 @@ describe('TextareaComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('aria', () => { + it('should have aria-invalid attribute', () => { + component.variant = 'error'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.hasAttribute('aria-invalid')).toBe(true); + }); + + it('should have aria-invalid value false', () => { + component.variant = 'default'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.getAttribute('aria-invalid')).toEqual('false'); + }); + + it('should have aria-invalid value true', () => { + component.variant = 'error'; + + fixture.detectChanges(); + + const element: HTMLElement = getElementFromFixture(fixture, getDataTestIdOf(dataTestId)); + expect(element.getAttribute('aria-invalid')).toEqual('true'); + }); + }); + + describe('focus', () => { + beforeEach(() => { + component.textAreaElement.nativeElement.focus = jest.fn(); + }); + + it('should focus', () => { + component.focus = true; + + expect(component.textAreaElement.nativeElement.focus).toHaveBeenCalled(); + }); + + it('should not focus', () => { + component.focus = false; + + expect(component.textAreaElement.nativeElement.focus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts index 9574b15496bbcf1cff81387b35383f480aaa0596..21e6c863afcc41c876cdfd0fa45704770ce39d56 100644 --- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts @@ -1,8 +1,8 @@ -import { convertForDataTest } from '@alfa-client/tech-shared'; +import { convertForDataTest, TechSharedModule } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva, VariantProps } from 'class-variance-authority'; const textareaVariants = cva( 'block w-full rounded-lg border bg-background-50 px-3 py-2 text-base text-text leading-5 focus:border-primary focus:ring-primary outline-none', @@ -23,12 +23,13 @@ type TextareaVariants = VariantProps<typeof textareaVariants>; @Component({ selector: 'ods-textarea', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, ReactiveFormsModule, TechSharedModule], template: ` <div class="mt-2"> <label [for]="id" class="text-md mb-2 block font-medium text-text" - >{{ label }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container></label - > + >{{ label }} + <ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> + </label> <textarea [id]="id" [formControl]="fieldControl" @@ -37,12 +38,17 @@ type TextareaVariants = VariantProps<typeof textareaVariants>; [placeholder]="placeholder" [autocomplete]="autocomplete" [required]="required" + [attr.aria-invalid]="variant === 'error'" + [attr.data-test-id]="(label | convertForDataTest) + '-textarea'" + #textAreaElement ></textarea> <ng-content select="[error]"></ng-content> </div> `, }) export class TextareaComponent { + @ViewChild('textAreaElement') textAreaElement: ElementRef; + @Input({ required: true }) label!: string; @Input({ required: true }) placeholder!: string; @Input() error: string; @@ -52,6 +58,12 @@ export class TextareaComponent { @Input() fieldControl: FormControl; @Input() required: boolean = false; + @Input() set focus(value: boolean) { + if (value && this.textAreaElement) { + this.textAreaElement.nativeElement.focus(); + } + } + textareaVariants = textareaVariants; get id(): string { diff --git a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.spec.ts b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.spec.ts index 65c4b159b81ac89cca0b3d82e7f086d9ccf921c0..1472e6fe3c880b33faeaf14e875f6973dde18a73 100644 --- a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.spec.ts @@ -25,22 +25,14 @@ import { CommandResource } from '@alfa-client/command-shared'; import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { Resource } from '@ngxp/rest'; import { cold } from 'jest-marbles'; -import { - createApiError, - createInvalidParam, - createIssue, - createProblemDetail, -} from 'libs/tech-shared/test/error'; +import { createApiError, createInvalidParam, createIssue, createProblemDetail } from 'libs/tech-shared/test/error'; import { Observable, of } from 'rxjs'; import { AbstractFormService } from './formservice.abstract'; -import { - StateResource, - createEmptyStateResource, - createErrorStateResource, -} from '../resource/resource.util'; +import { createEmptyStateResource, createErrorStateResource, createStateResource, StateResource } from '../resource/resource.util'; import { ApiError, HttpError, InvalidParam, Issue, ProblemDetail } from '../tech.model'; +import { createCommandResource } from '../../../../command-shared/test/command'; import * as ValidationUtil from '../validation/tech.validation.util'; describe('AbstractFormService', () => { @@ -63,6 +55,7 @@ describe('AbstractFormService', () => { TestFormService.SUBMIT_OBSERVABLE = () => of(stateResourceWithError); formService.handleResponse = jest.fn((stateResource) => stateResource); }); + it('should call handle response for api error', (done) => { formService.submit().subscribe(() => { expect(formService.handleResponse).toHaveBeenCalledWith(stateResourceWithError); @@ -77,6 +70,30 @@ describe('AbstractFormService', () => { expect(submitObservable).toBeObservable(cold('(a|)', { a: stateResourceWithError })); }); }); + + it('should call after submit operator function', (done) => { + const commandStateResource: StateResource<CommandResource> = + createStateResource(createCommandResource()); + const afterSubmit = () => of(commandStateResource); + formService.handleResponse = jest.fn(); + + formService.submit(afterSubmit).subscribe(() => { + expect(formService.handleResponse).toHaveBeenCalledWith(commandStateResource); + done(); + }); + }); + + it('should pass through as default after submit', (done) => { + const commandStateResource: StateResource<CommandResource> = + createStateResource(createCommandResource()); + TestFormService.SUBMIT_OBSERVABLE = () => of(commandStateResource); + formService.handleResponse = jest.fn(); + + formService.submit().subscribe(() => { + expect(formService.handleResponse).toHaveBeenCalledWith(commandStateResource); + done(); + }); + }); }); describe('handleResponse', () => { diff --git a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts index a02d2bab0a0eadcc998270d2d06adc277961d236..ab647437a8483fb73bccc4fa0e4d083ef8c48cde 100644 --- a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts +++ b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts @@ -25,15 +25,12 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Resource } from '@ngxp/rest'; import { isNil } from 'lodash-es'; -import { Observable } from 'rxjs'; +import { identity, Observable, OperatorFunction } from 'rxjs'; import { map } from 'rxjs/operators'; -import { StateResource, hasStateResourceError } from '../resource/resource.util'; +import { hasStateResourceError, StateResource } from '../resource/resource.util'; import { ApiError, HttpError, InvalidParam, Issue, ProblemDetail } from '../tech.model'; import { isNotUndefined } from '../tech.util'; -import { - setInvalidParamValidationError, - setIssueValidationError, -} from '../validation/tech.validation.util'; +import { setInvalidParamValidationError, setIssueValidationError } from '../validation/tech.validation.util'; export abstract class AbstractFormService { form: UntypedFormGroup; @@ -48,8 +45,13 @@ export abstract class AbstractFormService { protected abstract initForm(): UntypedFormGroup; - public submit(): Observable<StateResource<Resource | HttpError>> { - return this.doSubmit().pipe(map((result) => this.handleResponse(result))); + public submit( + afterSubmit: OperatorFunction<StateResource<Resource>, StateResource<Resource>> = identity, + ): Observable<StateResource<Resource | HttpError>> { + return this.doSubmit().pipe( + afterSubmit, + map((result) => this.handleResponse(result)), + ); } protected abstract doSubmit(): Observable<StateResource<Resource | HttpError>>; diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.spec.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.spec.ts index 6cad6f19354bc4784e455e1f93fe7b1f0687eccf..9e29f9d8172e162e37e5187cb5729efe2dd45eaf 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.spec.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.spec.ts @@ -467,6 +467,34 @@ describe('BescheidenFormService', () => { }); }); + describe('isBetreffInvalid', () => { + it('should return true', () => { + service.form.controls[BescheidenFormService.FIELD_NACHRICHT_SUBJECT].setErrors({}); + + expect(service.isBetreffInvalid()).toBeTruthy(); + }); + + it('should return false', () => { + service.form.controls[BescheidenFormService.FIELD_NACHRICHT_SUBJECT].setErrors(null); + + expect(service.isBetreffInvalid()).toBeFalsy(); + }); + }); + + describe('isNachrichtInvalid', () => { + it('should return true', () => { + service.form.controls[BescheidenFormService.FIELD_NACHRICHT_TEXT].setErrors({}); + + expect(service.isNachrichtInvalid()).toBeTruthy(); + }); + + it('should return false', () => { + service.form.controls[BescheidenFormService.FIELD_NACHRICHT_TEXT].setErrors(null); + + expect(service.isNachrichtInvalid()).toBeFalsy(); + }); + }); + describe('updateNachrichtOnSuccess', () => { const subject: string = faker.lorem.words(3); const text: string = faker.lorem.text(); diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.ts index 2ebd9db5811d3b3c7752ded2708e7c3156c55294..d9e05716a5c15264f7f5b671ecec6a8f61cc1ca1 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/bescheiden.formservice.ts @@ -301,4 +301,12 @@ export class BescheidenFormService extends AbstractFormService implements OnDest this.showMissingBescheidDocumentError$.next(isEmpty(this.getValue().bescheidDocument)); return isNotEmpty(this.getValue().bescheidDocument); } + + public isBetreffInvalid(): boolean { + return this.form.controls[BescheidenFormService.FIELD_NACHRICHT_SUBJECT].invalid; + } + + public isNachrichtInvalid(): boolean { + return this.form.controls[BescheidenFormService.FIELD_NACHRICHT_TEXT].invalid; + } } diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.html b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.html index 99306a896d5938ce7495270e0ca47ad89912e824..0b4b30cf95db4e50dbaec098d73c7b5fe1d0399b 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.html +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.html @@ -11,7 +11,7 @@ [formControlName]="formServiceClass.FIELD_NACHRICHT_SUBJECT" label="Betreff" placeholder="Betreff hier eingeben" - required="true" + [focus]="focusBetreff" > </ods-text-editor> @@ -19,7 +19,7 @@ [formControlName]="formServiceClass.FIELD_NACHRICHT_TEXT" label="Text" placeholder="Nachrichtentext hier eingeben" - required="true" + [focus]="focusNachricht" > </ods-textarea-editor> </div> diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.spec.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.spec.ts index 893c337ba18e0c86c6454628e475431305b092ba..892c0f51c55deb828359fa2df54a45701f137583 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.spec.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.spec.ts @@ -1,9 +1,9 @@ import { BescheidService, DocumentResource } from '@alfa-client/bescheid-shared'; import { - StateResource, createEmptyStateResource, createErrorStateResource, createStateResource, + StateResource, } from '@alfa-client/tech-shared'; import { getElementFromFixture } from '@alfa-client/test-utils'; import { registerLocaleData } from '@angular/common'; @@ -11,7 +11,7 @@ import localeDe from '@angular/common/locales/de'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; import faker from '@faker-js/faker'; -import { TextEditorComponent, TextareaEditorComponent } from '@ods/component'; +import { TextareaEditorComponent, TextEditorComponent } from '@ods/component'; import { Mock, mock, useFromMock } from 'libs/test-utils/src/lib/mocking'; import { OzgcloudSvgIconComponent } from 'libs/ui/src/lib/ui/ozgcloud-svgicon/ozgcloud-svgicon.component'; import { MockComponent } from 'ng-mocks'; @@ -111,12 +111,23 @@ describe('VorgangDetailBescheidenResultNachrichtComponent', () => { it('should patch form', () => { const documentStateResource: StateResource<DocumentResource> = createStateResource(createDocumentResource()); + jest.spyOn(formService.form, 'valid', 'get').mockReturnValue(true); component.bescheidDocumentStateResource = documentStateResource; expect(formService.patchNachricht).toHaveBeenCalledWith(documentStateResource.resource); }); + it('should not patch if form is invalid', () => { + const documentStateResource: StateResource<DocumentResource> = + createStateResource(createDocumentResource()); + jest.spyOn(formService.form, 'valid', 'get').mockReturnValue(false); + + component.bescheidDocumentStateResource = documentStateResource; + + expect(formService.patchNachricht).not.toHaveBeenCalledWith(documentStateResource.resource); + }); + it('should not patch form if document loading', () => { component.bescheidDocumentStateResource = { ...createEmptyStateResource(), loading: true }; @@ -138,4 +149,66 @@ describe('VorgangDetailBescheidenResultNachrichtComponent', () => { }, ); }); + + describe('resetFocus', () => { + it('should reset betreff focus', () => { + component.focusBetreff = true; + + component.resetFocus(); + + expect(component.focusBetreff).toBeFalsy(); + }); + + it('should reset nachricht focus', () => { + component.focusNachricht = true; + + component.resetFocus(); + + expect(component.focusNachricht).toBeFalsy(); + }); + }); + + describe('sendWithNachrichtCommandStateResource', () => { + beforeEach(() => { + component.resetFocus = jest.fn(); + }); + + it('should reset focus', () => { + component.sendWithNachrichtCommandStateResource = createEmptyStateResource(); + + expect(component.resetFocus).toHaveBeenCalled(); + }); + + it('should focus betreff', () => { + component.focusBetreff = false; + formService.isBetreffInvalid = jest.fn().mockReturnValue(true); + formService.isNachrichtInvalid = jest.fn().mockReturnValue(false); + + component.sendWithNachrichtCommandStateResource = createErrorStateResource(createApiError()); + + expect(component.focusBetreff).toBeTruthy(); + }); + + it('should focus nachricht', () => { + component.focusNachricht = false; + formService.isBetreffInvalid = jest.fn().mockReturnValue(false); + formService.isNachrichtInvalid = jest.fn().mockReturnValue(true); + + component.sendWithNachrichtCommandStateResource = createErrorStateResource(createApiError()); + + expect(component.focusNachricht).toBeTruthy(); + }); + + it('should focus betreff first', () => { + component.focusNachricht = false; + component.focusBetreff = false; + formService.isBetreffInvalid = jest.fn().mockReturnValue(true); + formService.isNachrichtInvalid = jest.fn().mockReturnValue(true); + + component.sendWithNachrichtCommandStateResource = createErrorStateResource(createApiError()); + + expect(component.focusBetreff).toBeTruthy(); + expect(component.focusNachricht).toBeFalsy(); + }); + }); }); diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.ts index 9dd0fca6fb796f1d47f773d147fdd54954ae1756..71e3ba8875bb20787f28dc4027caa5ab643d8088 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result-nachricht/vorgang-detail-bescheiden-result-nachricht.component.ts @@ -1,5 +1,6 @@ import { BescheidService, DocumentResource } from '@alfa-client/bescheid-shared'; -import { StateResource, isNotNil } from '@alfa-client/tech-shared'; +import { CommandResource } from '@alfa-client/command-shared'; +import { hasStateResourceError, isNotNil, StateResource } from '@alfa-client/tech-shared'; import { Component, Input, OnInit } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -10,15 +11,38 @@ import { BescheidenFormService } from '../../bescheiden.formservice'; templateUrl: './vorgang-detail-bescheiden-result-nachricht.component.html', }) export class VorgangDetailBescheidenResultNachrichtComponent implements OnInit { - @Input() set bescheidDocumentStateResource(stateResource: StateResource<DocumentResource>) { + @Input() + set bescheidDocumentStateResource(stateResource: StateResource<DocumentResource>) { if (!stateResource.loading && !stateResource.error && isNotNil(stateResource.resource)) { - this.formService.patchNachricht(stateResource.resource); + if (isNotNil(this.form) && this.form.valid) { + this.formService.patchNachricht(stateResource.resource); + } } } + @Input() + set sendWithNachrichtCommandStateResource(stateResource: StateResource<CommandResource>) { + this.resetFocus(); + if (isNotNil(stateResource) && hasStateResourceError(stateResource)) { + if (this.formService.isBetreffInvalid()) { + this.focusBetreff = true; + } else if (this.formService.isNachrichtInvalid()) { + this.focusNachricht = true; + } + } + } + + resetFocus() { + this.focusBetreff = false; + this.focusNachricht = false; + } + empfaenger$: Observable<string>; form: FormGroup; + public focusBetreff: boolean = false; + public focusNachricht: boolean = false; + readonly formServiceClass = BescheidenFormService; constructor( diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.html b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.html index 158859aef477901045106b1b0e6d60fe2cdab6e2..99468af54c2fca65dc40fd83f9f6e20a12c1c0bf 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.html +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.html @@ -32,6 +32,7 @@ <div *ngIf="!wizardData.sendByManual && wizardData.activeStep === 3 && (canSend$ | async)"> <alfa-vorgang-detail-bescheiden-result-nachricht [bescheidDocumentStateResource]="bescheidDocument$ | async" + [sendWithNachrichtCommandStateResource]="sendWithNachricht$ | async" data-test-id="bescheid-nachricht-an-antragsteller" ></alfa-vorgang-detail-bescheiden-result-nachricht> </div> diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts index 918867cb02f084f1d79f24aca4ac1e8cdfbd5283..09b86fb5cca6a3e276234985d4c980d39cae2ab9 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts @@ -1,11 +1,15 @@ import { BescheidLinkRel, BescheidResource, BescheidService } from '@alfa-client/bescheid-shared'; import { CommandOrder, CommandResource } from '@alfa-client/command-shared'; -import { StateResource, createStateResource } from '@alfa-client/tech-shared'; import { - Mock, + createEmptyStateResource, + createStateResource, + StateResource, +} from '@alfa-client/tech-shared'; +import { dispatchEventFromFixture, existsAsHtmlElement, getElementFromFixture, + Mock, mock, notExistsAsHtmlElement, } from '@alfa-client/test-utils'; @@ -51,6 +55,8 @@ describe('VorgangDetailBescheidenResultComponent', () => { formService.getBescheidChanges.mockReturnValue( new BehaviorSubject({ beschiedenAm: new Date(), bewilligt: false }), ); + formService.getActiveStep.mockReturnValue(EMPTY); + await TestBed.configureTestingModule({ declarations: [ VorgangDetailBescheidenResultComponent, @@ -210,6 +216,26 @@ describe('VorgangDetailBescheidenResultComponent', () => { expect(formService.getActiveStep).toHaveBeenCalled(); }); + it.each([1, 2])('should reset save and send in progress in step %d', (step: number) => { + formService.getActiveStep.mockReturnValue(of(step)); + component.resetSend = jest.fn(); + + component.ngOnInit(); + + component.activeStep$.subscribe(); + expect(component.resetSend).toHaveBeenCalled(); + }); + + it('should not reset save and send in progress in last step', () => { + formService.getActiveStep.mockReturnValue(of(3)); + component.resetSend = jest.fn(); + + component.ngOnInit(); + + component.activeStep$.subscribe(); + expect(component.resetSend).not.toHaveBeenCalled(); + }); + it('should call formservice to get current bescheid/formular', () => { component.ngOnInit(); @@ -383,16 +409,18 @@ describe('VorgangDetailBescheidenResultComponent', () => { expect(formService.submit).toHaveBeenCalled(); }); - it('should call send on successfully done command', () => { + it('should call send after successfully update', () => { + component.doSendAfterSuccessfullyUpdate = jest.fn(); const sendMock = jest.fn(); - component.doUpdateAndSend(bescheidDraft, sendMock).pipe(first()).subscribe(); + component.doUpdateAndSend(bescheidDraft, sendMock).subscribe(); - expect(sendMock).toHaveBeenCalled(); + expect(component.doSendAfterSuccessfullyUpdate).toHaveBeenCalledWith(bescheidDraft, sendMock); }); it('should close dialog on successfully done command', () => { - const sendMock = jest.fn().mockReturnValue( + const sendMock = jest.fn(); + formService.submit = jest.fn().mockReturnValue( of( createStateResource({ ...createCommandResource([CommandLinkRel.EFFECTED_RESOURCE]), @@ -408,6 +436,34 @@ describe('VorgangDetailBescheidenResultComponent', () => { }); }); + describe('doSendAfterSuccessfullyUpdate', () => { + const bescheidDraft: BescheidResource = createBescheidResource(); + + it('should call send', () => { + const commandStateResource: StateResource<CommandResource> = createStateResource( + createCommandResource([CommandLinkRel.EFFECTED_RESOURCE]), + ); + const sendMock = jest.fn(); + + component + .doSendAfterSuccessfullyUpdate(bescheidDraft, sendMock)(of(commandStateResource)) + .subscribe(); + + expect(sendMock).toHaveBeenCalled(); + }); + + it('should not call send', () => { + const commandStateResource: StateResource<CommandResource> = createEmptyStateResource(); + const sendMock = jest.fn(); + + component + .doSendAfterSuccessfullyUpdate(bescheidDraft, sendMock)(of(commandStateResource)) + .subscribe(); + + expect(sendMock).not.toHaveBeenCalled(); + }); + }); + describe('bescheid status text', () => { it('should NOT be visible on NOT send by manual', () => { component.sendByManual$ = of(false); @@ -527,4 +583,34 @@ describe('VorgangDetailBescheidenResultComponent', () => { notExistsAsHtmlElement(fixture, saveAndSendButton); }); }); + + describe('resetSend', () => { + it('should set to empty state resource', () => { + component.saveAndSendInProgress$ = of(createStateResource(createCommandResource())); + + component.resetSend(); + + expect(component.saveAndSendInProgress$).toBeObservable( + singleColdCompleted(createEmptyStateResource()), + ); + }); + }); + + describe('ifNotLastStep', () => { + it.each([1, 2])('should do it in step %d', (step: number) => { + const doIt = jest.fn(); + + of(step).pipe(component.ifNotLastStep(doIt)).subscribe(); + + expect(doIt).toHaveBeenCalled(); + }); + + it('should not do it in last step', () => { + const doIt = jest.fn(); + + of(3).pipe(component.ifNotLastStep(doIt)).subscribe(); + + expect(doIt).not.toHaveBeenCalled(); + }); + }); }); diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.ts index 29e7e18dfa836123bfbf1232551170fc20224012..9ffa6be076415cd7e55d777845c5b7a28c04322d 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.ts @@ -15,8 +15,8 @@ import { } from '@alfa-client/command-shared'; import { createEmptyStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared'; import { Component, EventEmitter, OnInit, Output } from '@angular/core'; -import { hasLink } from '@ngxp/rest'; -import { filter, map, Observable, of } from 'rxjs'; +import { hasLink, Resource } from '@ngxp/rest'; +import { filter, map, Observable, of, OperatorFunction, tap } from 'rxjs'; import { BescheidenFormService } from '../bescheiden.formservice'; type sendBescheid = ( @@ -28,6 +28,8 @@ type sendBescheid = ( templateUrl: './vorgang-detail-bescheiden-result.component.html', }) export class VorgangDetailBescheidenResultComponent implements OnInit { + private static readonly LAST_STEP: number = 3; + @Output() closeDialog: EventEmitter<void> = new EventEmitter(); public activeStep$: Observable<number>; @@ -55,6 +57,10 @@ export class VorgangDetailBescheidenResultComponent implements OnInit { createEmptyStateResource<CommandResource>(), ); + public sendWithNachricht$: Observable<StateResource<CommandResource>> = of( + createEmptyStateResource<CommandResource>(), + ); + public canSave$: Observable<boolean> = of(true); public canSend$: Observable<boolean> = of(true); public showMissingBescheidDocumentError$: Observable<boolean> = of(false); @@ -75,7 +81,9 @@ export class VorgangDetailBescheidenResultComponent implements OnInit { this.bescheidService.getCreateBescheidDocumentInProgress(); this.bescheidDocument$ = this.bescheidService.getBescheidDocument(); - this.activeStep$ = this.formService.getActiveStep(); + this.activeStep$ = this.formService + .getActiveStep() + .pipe(this.ifNotLastStep(() => this.resetSend())); this.bescheid$ = this.formService.getBescheidChanges(); this.sendByManual$ = this.formService.isSendByManual(); @@ -94,6 +102,23 @@ export class VorgangDetailBescheidenResultComponent implements OnInit { this.showMissingBescheidDocumentError$ = this.formService.getShowMissingBescheidDocumentError(); } + ifNotLastStep(doIt: () => void): OperatorFunction<number, number> { + return (source: Observable<number>) => { + return source.pipe( + tap((step: number) => { + if (step < VorgangDetailBescheidenResultComponent.LAST_STEP) { + doIt(); + } + }), + ); + }; + } + + resetSend(): void { + this.saveAndSendInProgress$ = of(createEmptyStateResource<CommandResource>()); + this.sendWithNachricht$ = of(createEmptyStateResource<CommandResource>()); + } + public deleteBescheidDocument(): void { this.formService.clearBescheidDocumentFile(); this.bescheidService.deleteBescheidDocument(); @@ -107,17 +132,17 @@ export class VorgangDetailBescheidenResultComponent implements OnInit { } public saveAndSendWithNachricht(bescheidDraft: BescheidResource): void { - this.saveAndSendInProgress$ = this.doUpdateAndSend(bescheidDraft, (bescheidResource) => - this.bescheidService.sendBescheidToAntragsteller(bescheidResource), - ); + this.saveAndSendInProgress$ = this.doUpdateAndSend(bescheidDraft, (bescheidResource) => { + this.sendWithNachricht$ = this.bescheidService.sendBescheidToAntragsteller(bescheidResource); + return this.sendWithNachricht$; + }); } doUpdateAndSend( bescheidDraft: BescheidResource, send: sendBescheid, ): Observable<StateResource<CommandResource>> { - return this.formService.submit().pipe( - switchMapCommandSuccessfullyDone(() => send(bescheidDraft)), + return this.formService.submit(this.doSendAfterSuccessfullyUpdate(bescheidDraft, send)).pipe( tapOnCommandSuccessfullyDone((commandStateResource: StateResource<CommandResource>) => { if (this.isSendBescheidCommand(commandStateResource)) { this.closeDialog.emit(); @@ -126,6 +151,13 @@ export class VorgangDetailBescheidenResultComponent implements OnInit { ); } + doSendAfterSuccessfullyUpdate( + bescheidDraft: BescheidResource, + send: sendBescheid, + ): OperatorFunction<StateResource<Resource>, StateResource<Resource>> { + return switchMapCommandSuccessfullyDone(() => send(bescheidDraft)); + } + private isSendBescheidCommand(commandStateResource: StateResource<CommandResource>): boolean { return commandStateResource.resource.order == CommandOrder.SEND_BESCHEID; } diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/Bescheid.java b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/Bescheid.java index 67cb7eb4c7f5432aa2433a65b784c788f4b9869c..76d8324789bfcec50208634004b8082eef732414 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/Bescheid.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/Bescheid.java @@ -1,7 +1,10 @@ package de.ozgcloud.alfa.bescheid; +import static de.ozgcloud.alfa.common.ValidationMessageCodes.*; + import java.util.List; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -53,7 +56,9 @@ public class Bescheid implements CommandBody { @LinkedResource(controllerClass = BinaryFileController.class) private List<FileId> attachments; + @NotEmpty(message = FIELD_IS_EMPTY, groups = BescheidNachrichtValidation.class) private String nachrichtText; + @NotEmpty(message = FIELD_IS_EMPTY, groups = BescheidNachrichtValidation.class) private String nachrichtSubject; private SendBy sendBy; private BescheidStatus status; diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidCommandController.java b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidCommandController.java new file mode 100644 index 0000000000000000000000000000000000000000..bbafe89749c95817519ef54be25ff303950b84c0 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidCommandController.java @@ -0,0 +1,47 @@ +package de.ozgcloud.alfa.bescheid; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.ozgcloud.alfa.common.command.Command; +import de.ozgcloud.alfa.common.command.CommandController; +import de.ozgcloud.alfa.common.command.CommandService; +import de.ozgcloud.alfa.common.command.CreateCommand; +import de.ozgcloud.common.errorhandling.TechnicalException; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping(BescheidCommandController.PATH) +@RequiredArgsConstructor +class BescheidCommandController { + + static final String PATH = "/api/vorgangs/{vorgangId}/bescheids/{bescheidId}/{bescheidVersion}/commands"; // NOSONAR + + private final BescheidService bescheidService; + private final CommandService commandService; + private final BescheidSendenCommandValidator bescheidSendenCommandValidator; + + @PostMapping + public ResponseEntity<EntityModel<Command>> createCommand(@PathVariable String vorgangId, @PathVariable String bescheidId, + @PathVariable long bescheidVersion, @RequestBody CreateCommand command) { + command = command.toBuilder().vorgangId(vorgangId).relationId(bescheidId).build(); + + validate(vorgangId, command); + + var created = commandService.createCommand(command, bescheidVersion); + return ResponseEntity.created(linkTo(CommandController.class).slash(created.getId()).toUri()).build(); + } + + void validate(String vorgangId, CreateCommand command) { + var bescheid = bescheidService.getBescheidDraft(vorgangId) + .orElseThrow(() -> new TechnicalException("BescheidDraft not found")); + bescheidSendenCommandValidator.validate(command.toBuilder().body(bescheid).build()); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidModelAssembler.java b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidModelAssembler.java index f8d3c4a12eb145cdc898cf59d783f01e8c0d935c..c0f66edba7f3ef926872008c378d0e586a105fbd 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidModelAssembler.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidModelAssembler.java @@ -61,6 +61,9 @@ public class BescheidModelAssembler implements RepresentationModelAssembler<Besc var attachmentsLink = linkTo(methodOn(BescheidController.class).getAttachments(bescheid.getId(), bescheid.getVorgangId())); var createCommandLink = buildCreateCommandLink(bescheid); var vorgangWithEingang = vorgangController.getVorgang(bescheid.getVorgangId()); + var bescheidenUndSendenLink = linkTo( + methodOn(BescheidCommandController.class).createCommand(vorgangWithEingang.getId(), bescheid.getId(), bescheid.getVersion(), + null)); return ModelBuilder.fromEntity(bescheid) .addLink(selfLink.withSelfRel()) @@ -75,7 +78,7 @@ public class BescheidModelAssembler implements RepresentationModelAssembler<Besc .addLink(createCommandLink.withRel(REL_CREATE_DOCUMENT)) .addLink(createCommandLink.withRel(REL_CREATE_DOCUMENT_FROM_FILE)) .ifMatch(() -> canSendMessageToAntragsteller(vorgangWithEingang)) - .addLink(createCommandLink.withRel(REL_BESCHEIDEN_UND_SENDEN)) + .addLink(bescheidenUndSendenLink.withRel(REL_BESCHEIDEN_UND_SENDEN)) .addLink(createCommandLink.withRel(REL_BESCHEIDEN)) .buildModel(); } diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidNachrichtValidation.java b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidNachrichtValidation.java new file mode 100644 index 0000000000000000000000000000000000000000..13e50b003feb81845b571a78148235252d852457 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidNachrichtValidation.java @@ -0,0 +1,4 @@ +package de.ozgcloud.alfa.bescheid; + +interface BescheidNachrichtValidation { +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandValidator.java b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..f304a0428a6a923900e5ba657d16cc8faea20d6f --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandValidator.java @@ -0,0 +1,17 @@ +package de.ozgcloud.alfa.bescheid; + +import jakarta.validation.Valid; + +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import de.ozgcloud.alfa.common.command.CreateCommand; + +@Validated(BescheidNachrichtValidation.class) +@Component +class BescheidSendenCommandValidator { + + public void validate(@Valid CreateCommand command) { + // noop + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java index 4372f02417565a329e9fe8b3b25bc5b7556e2611..159f038b04b56863c3532950ec5ebef062bf48f4 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java @@ -177,7 +177,7 @@ class BescheidModelAssemblerTest { assertThat(model.getLink(REL_BESCHEIDEN_UND_SENDEN)) .isPresent().get() - .extracting(Link::getHref).isEqualTo(createCommandLink()); + .extracting(Link::getHref).isEqualTo(bescheidenUndSendenLink()); } @Test @@ -225,6 +225,11 @@ class BescheidModelAssemblerTest { .expand(VorgangHeaderTestFactory.ID, BescheidTestFactory.ID, BescheidTestFactory.VERSION).toString(); } + private String bescheidenUndSendenLink() { + return new UriTemplate(BescheidCommandController.PATH) + .expand(VorgangHeaderTestFactory.ID, BescheidTestFactory.ID, BescheidTestFactory.VERSION).toString(); + } + private EntityModel<Bescheid> callToModel() { return callToModel(bescheid); } @@ -240,6 +245,8 @@ class BescheidModelAssemblerTest { @Test void shouldCallToModel() { + when(vorgangController.getVorgang(VorgangHeaderTestFactory.ID)).thenReturn(VorgangWithEingangTestFactory.create()); + callMethod(); verify(assembler).toCollectionModel(List.of(bescheid)); @@ -257,6 +264,8 @@ class BescheidModelAssemblerTest { @Test void shouldHaveSelfLink() { + when(vorgangController.getVorgang(VorgangHeaderTestFactory.ID)).thenReturn(VorgangWithEingangTestFactory.create()); + var collectionModel = callMethod(); assertThat(collectionModel.getLinks()) diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5bb9c0e7cc0eb3b363e62f723ab4c7cafc224f99 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidSendenCommandControllerTest.java @@ -0,0 +1,183 @@ +package de.ozgcloud.alfa.bescheid; + +import static de.ozgcloud.alfa.common.command.CommandController.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import de.ozgcloud.alfa.common.command.Command; +import de.ozgcloud.alfa.common.command.CommandOrder; +import de.ozgcloud.alfa.common.command.CommandService; +import de.ozgcloud.alfa.common.command.CommandTestFactory; +import de.ozgcloud.alfa.common.command.CreateCommand; +import de.ozgcloud.alfa.vorgang.VorgangHeaderTestFactory; +import de.ozgcloud.common.errorhandling.TechnicalException; +import lombok.SneakyThrows; + +class BescheidSendenCommandControllerTest { + + @Spy + @InjectMocks + private BescheidCommandController controller; + + @Mock + private BescheidService bescheidService; + @Mock + private CommandService commandService; + + @Mock + private BescheidSendenCommandValidator bescheidSendenCommandValidator; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Nested + class TestCreateCommand { + + private final Command command = CommandTestFactory.create(); + + @Captor + private ArgumentCaptor<CreateCommand> createCommandArgumentCaptor; + + @BeforeEach + void setUp() { + doNothing().when(controller).validate(eq(VorgangHeaderTestFactory.ID), any(CreateCommand.class)); + when(commandService.createCommand(any(CreateCommand.class), eq(BescheidTestFactory.VERSION))).thenReturn(command); + } + + @SneakyThrows + @Test + void shouldValidate() { + doRequest(); + + verify(controller).validate(eq(VorgangHeaderTestFactory.ID), any(CreateCommand.class)); + } + + @SneakyThrows + @Test + void shouldSetVorgangIdOnCreateCommand() { + doRequest(); + + verify(controller).validate(eq(VorgangHeaderTestFactory.ID), createCommandArgumentCaptor.capture()); + assertThat(createCommandArgumentCaptor.getValue().getVorgangId()).isEqualTo(VorgangHeaderTestFactory.ID); + } + + @SneakyThrows + @Test + void shouldSetRelationIdOnCreateCommand() { + doRequest(); + + verify(controller).validate(eq(VorgangHeaderTestFactory.ID), createCommandArgumentCaptor.capture()); + assertThat(createCommandArgumentCaptor.getValue().getRelationId()).isEqualTo(BescheidTestFactory.ID); + } + + @SneakyThrows + @Test + void shouldCallCommandService() { + doRequest(); + + verify(commandService).createCommand(any(CreateCommand.class), eq(BescheidTestFactory.VERSION)); + } + + @SneakyThrows + @Test + void shouldReturnCreated() { + var response = doRequest(); + + response.andExpect(status().isCreated()); + } + + @SneakyThrows + @Test + void shouldReturnCommand() { + doRequest() + .andExpect(header().stringValues("location", "http://localhost" + COMMANDS_PATH + "/" + CommandTestFactory.ID)); + } + + @SneakyThrows + private ResultActions doRequest() { + return mockMvc.perform( + post(BescheidCommandController.PATH, + VorgangHeaderTestFactory.ID, + BescheidTestFactory.ID, + BescheidTestFactory.VERSION) + .content(CommandTestFactory.buildCreateCommandContent(CommandOrder.SEND_BESCHEID)) + .contentType(MediaType.APPLICATION_JSON)); + } + + } + + @Nested + class TestValidate { + + private final CreateCommand createCommand = CommandTestFactory.createCreateCommand(); + private final Bescheid bescheid = BescheidTestFactory.create(); + + @Captor + private ArgumentCaptor<CreateCommand> commandArgumentCaptor; + + @Nested + class WhenBescheidExists { + + @BeforeEach + void setUp() { + when(bescheidService.getBescheidDraft(VorgangHeaderTestFactory.ID)).thenReturn(Optional.of(bescheid)); + doNothing().when(bescheidSendenCommandValidator).validate(any(CreateCommand.class)); + } + + @Test + void shouldGetBescheidDraft() { + controller.validate(VorgangHeaderTestFactory.ID, createCommand); + + verify(bescheidService).getBescheidDraft(VorgangHeaderTestFactory.ID); + } + + @Test + void shouldValidate() { + controller.validate(VorgangHeaderTestFactory.ID, createCommand); + + verify(bescheidSendenCommandValidator).validate(any(CreateCommand.class)); + } + + @Test + void shouldAddBescheidToCommand() { + controller.validate(VorgangHeaderTestFactory.ID, createCommand); + + verify(bescheidSendenCommandValidator).validate(commandArgumentCaptor.capture()); + assertThat(commandArgumentCaptor.getValue()).usingRecursiveComparison().isEqualTo(createCommand.toBuilder().body(bescheid).build()); + } + } + + @Nested + class WhenBescheidNotExists { + + @Test + void shouldThrowTechnicalException() { + when(bescheidService.getBescheidDraft(VorgangHeaderTestFactory.ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.validate(VorgangHeaderTestFactory.ID, createCommand)).isInstanceOf(TechnicalException.class); + } + } + } + +} \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandITCase.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandITCase.java index 38bcfb3ea39aaa6bed30d27089e6b557076e5a40..7ee0005a679ae552bd6e78cdc24ae00e6ab99213 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandITCase.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandITCase.java @@ -94,7 +94,7 @@ public class CommandITCase { } private String createContent() { - return TestUtils.loadTextFile("jsonTemplates/command/createCommandWithBody.json.tmpl", CommandOrder.ASSIGN_USER.name(), + return CommandTestFactory.buildCreateCommandWithBodyContent(CommandOrder.ASSIGN_USER, TestUtils.loadTextFile("jsonTemplates/command/commandAssignedToBody", TestUtils.addQuote("/api/users/" + UserProfileTestFactory.ID.toString()))); } @@ -299,9 +299,9 @@ public class CommandITCase { ResultActions doRequest(String content) throws Exception { return mockMvc.perform(post("/api/vorgangs/" + CommandTestFactory.VORGANG_ID + "/relations/" + CommandTestFactory.RELATION_ID + "/" + CommandTestFactory.RELATION_VERSION + "/commands") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(content)); + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(content)); } } } \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTestFactory.java index ddeae6623ab16ea0ee506f310ad96220561ea451..614a48500ac9dadb18ccf9a135b7f769fbbb7e32 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTestFactory.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTestFactory.java @@ -79,4 +79,18 @@ public class CommandTestFactory { public static String buildCreateVorgangCommandContent(String order) { return TestUtils.loadTextFile("jsonTemplates/command/createVorgangCommand.json.tmpl", order); } + + public static String buildCreateCommandContent(CommandOrder order) { + return TestUtils.loadTextFile("jsonTemplates/command/createCommandWithBody.json.tmpl", + order.name(), + null); + + } + + public static String buildCreateCommandWithBodyContent(CommandOrder order, String body) { + return TestUtils.loadTextFile("jsonTemplates/command/createCommandWithBody.json.tmpl", + order.name(), + body); + + } } \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailCommandControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailCommandControllerTest.java index c074c935bd8cbb0a52a34fce1600e0f577247bdf..55d5e8e1fe0fc5dbf439fe49a21010bc182881ff 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailCommandControllerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailCommandControllerTest.java @@ -159,7 +159,7 @@ class PostfachMailCommandControllerTest { private ResultActions doRequest(CommandOrder order) { return mockMvc.perform(post("/api/postfachMails/" + PostfachMailTestFactory.ID + "/commands") .contentType(MediaType.APPLICATION_JSON) - .content(PostfachMailTestFactory.buildCreateCommandPostfachNachrichtContent(order))); + .content(CommandTestFactory.buildCreateCommandWithBodyContent(order, "{}"))); } } } \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailTestFactory.java index cd7b4ecdc14bbe650f2e49642233fffab1fc1a31..11d1d0ed7bb0c9aaf6b013b018346aa79c7169f6 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailTestFactory.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailTestFactory.java @@ -112,8 +112,4 @@ public class PostfachMailTestFactory { var fileId = postfachMail.getAttachments().get(0).toString(); return "[\"api/binaryFiles/" + fileId + "\"]"; } - - public static String buildCreateCommandPostfachNachrichtContent(CommandOrder order) { - return TestUtils.loadTextFile("jsonTemplates/command/createCommandWithBody.json.tmpl", order.name(), "{}"); - } } \ No newline at end of file