diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts index 038187decc6f18d2bac42ab33d94cdf3a751912a..16736b029cc38ba110105e478509ff4664306e23 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.spec.ts @@ -21,9 +21,16 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { createEmptyStateResource, createStateResource, EMPTY_STRING, InvalidParam, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared'; +import { + createEmptyStateResource, + createStateResource, + EMPTY_STRING, + InvalidParam, + setInvalidParamValidationError, + StateResource, +} from '@alfa-client/tech-shared'; import { Injectable } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, UntypedFormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; import { faker } from '@faker-js/faker/.'; @@ -173,6 +180,7 @@ describe('KeycloakFormService', () => { service._patchConfig = patchConfig; service._load = jest.fn().mockReturnValue(singleHot(dummyStateResource)); service._patchIfLoaded = jest.fn(); + service._doAfterPatch = jest.fn(); }); it('should call load', () => { @@ -189,6 +197,14 @@ describe('KeycloakFormService', () => { expect(service._patchIfLoaded).toHaveBeenCalledWith(dummyStateResource); }); + it('should call do after patch', () => { + service._load = jest.fn().mockReturnValue(of(dummyStateResource)); + + service._initLoading().subscribe(); + + expect(service._doAfterPatch).toHaveBeenCalledWith(dummyStateResource); + }); + it('should return loaded value', () => { const loadedDummyStateResource: Observable<StateResource<Dummy>> = service._initLoading(); @@ -276,9 +292,16 @@ describe('KeycloakFormService', () => { expect(service._showValidationErrorForAllInvalidControls).toHaveBeenCalledWith(service.form); }); - it('should return emit state resource', () => { - expect(service._processInvalidForm()).toBeObservable(singleColdCompleted(createEmptyStateResource())); - }); + it('should return delayed empty state resource', fakeAsync(() => { + const expectedEmits: StateResource<unknown>[] = []; + + service._processInvalidForm().subscribe((value: StateResource<unknown>) => { + expectedEmits.push(value); + }); + tick(200); + + expect(expectedEmits).toEqual([createEmptyStateResource(true), createEmptyStateResource()]); + })); }); describe('process response validation errors', () => { @@ -763,6 +786,8 @@ export class TestKeycloakFormService extends KeycloakFormService<Dummy> { return TestKeycloakFormService.LOAD_OBSERVABLE(); } + _doAfterPatch(stateResource: StateResource<Dummy>) {} + _doSubmit(): Observable<StateResource<Dummy>> { return TestKeycloakFormService.SUBMIT_OBSERVABLE(); } diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts index e1856df17c3952c3fde88e15d205b6838b9083ff..c8287dbaba567bfefb376671bba26a35e2dd8ad7 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-formservice.ts @@ -21,7 +21,14 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { createEmptyStateResource, InvalidParam, isLoaded, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared'; +import { + creatDelayedEmptyStateResource, + createEmptyStateResource, + InvalidParam, + isLoaded, + setInvalidParamValidationError, + StateResource, +} from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import { AbstractControl, FormArray, FormBuilder, FormGroup } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; @@ -62,11 +69,16 @@ export abstract class KeycloakFormService<T> { } _initLoading(): Observable<StateResource<T>> { - return this._load(this._patchConfig.id).pipe(tap((stateResource: StateResource<T>) => this._patchIfLoaded(stateResource))); + return this._load(this._patchConfig.id).pipe( + tap((stateResource: StateResource<T>) => this._patchIfLoaded(stateResource)), + tap((stateResource: StateResource<T>) => this._doAfterPatch(stateResource)), + ); } abstract _load(id: string): Observable<StateResource<T>>; + abstract _doAfterPatch(stateResource: StateResource<T>): void; + _patchIfLoaded(stateResource: StateResource<T>): void { if (isLoaded(stateResource)) this._patch(stateResource.resource); } @@ -82,7 +94,7 @@ export abstract class KeycloakFormService<T> { _processInvalidForm(): Observable<StateResource<T>> { this._showValidationErrorForAllInvalidControls(this.form); - return of(createEmptyStateResource<T>()); + return creatDelayedEmptyStateResource<T>(); } _processResponseValidationErrors(keycloakError: KeycloakHttpErrorResponse): Observable<StateResource<T>> { diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html index d3d39a71ceb69e639c38e1ac0219f27eb1c5e6f5..29de0bc1bced5b0b4cfbf4922f3594240ffcf37a 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html @@ -10,7 +10,13 @@ <div [formGroupName]="UserFormService.ADMINISTRATION_GROUP" class="flex flex-1 flex-col gap-2"> <h3 class="text-md block font-medium text-text">Administration</h3> <div class="flex items-center gap-2"> - <ods-checkbox-editor [formControlName]="UserFormService.ADMIN" label="Admin" inputId="admin" /> + <ods-checkbox-editor + [formControlName]="UserFormService.ADMIN" + (inputChange)="handleAdminRoleChange()" + data-test-id="checkbox-admin" + label="Admin" + inputId="admin" + /> <button data-test-id="admin-role-info-button" tooltip="Diese Rolle kann Funktionen der OZG-Cloud konfigurieren, z.B. Benutzer anlegen, Organisationseinheiten hinzufügen und Rollen zuweisen." @@ -21,6 +27,8 @@ <div class="flex items-center gap-2"> <ods-checkbox-editor [formControlName]="UserFormService.DATENBEAUFTRAGUNG" + (inputChange)="handleAdminRoleChange()" + data-test-id="checkbox-datenbeauftragung" label="Datenbeauftragung" inputId="datenbeauftragung" /> @@ -35,7 +43,13 @@ <div [formGroupName]="UserFormService.ALFA_GROUP" class="flex flex-1 flex-col gap-2"> <h3 class="text-md block font-medium text-text">Alfa</h3> <div class="flex items-center gap-2"> - <ods-checkbox-editor [formControlName]="UserFormService.LOESCHEN" label="Löschen" inputId="delete" /> + <ods-checkbox-editor + [formControlName]="UserFormService.LOESCHEN" + (inputChange)="handleAlfaRoleChange(UserFormService.LOESCHEN, $event)" + label="Löschen" + inputId="delete" + data-test-id="checkbox-loeschen" + /> <button data-test-id="loschen-role-info-button" tooltip='Diese Rolle hat dieselben Rechte wie die Rolle "User". Zusätzlich kann "Löschen" Löschanträge aus Alfa bestätigen. ' @@ -44,7 +58,13 @@ </button> </div> <div class="flex items-center gap-2"> - <ods-checkbox-editor [formControlName]="UserFormService.USER" label="User" inputId="user" /> + <ods-checkbox-editor + [formControlName]="UserFormService.USER" + (inputChange)="handleAlfaRoleChange(UserFormService.USER, $event)" + label="User" + inputId="user" + data-test-id="checkbox-user" + /> <button data-test-id="user-role-info-button" tooltip="Diese Rolle kann alle Vorgänge sehen und bearbeiten, wenn diese seiner Organisationseinheit zugewiesen sind." @@ -53,7 +73,13 @@ </button> </div> <div class="flex items-center gap-2"> - <ods-checkbox-editor [formControlName]="UserFormService.POSTSTELLE" label="Poststelle" inputId="post_office" /> + <ods-checkbox-editor + [formControlName]="UserFormService.POSTSTELLE" + (inputChange)="handleAlfaRoleChange(UserFormService.POSTSTELLE, $event)" + label="Poststelle" + inputId="post_office" + data-test-id="checkbox-poststelle" + /> <button data-test-id="poststelle-role-info-button" tooltip="Diese Rolle kann alle neu eingegangenen Vorgänge sehen."> <ods-info-icon /> </button> diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts index 5f21edb7a5722e98bd9a0069b3dcb3f9ce8ad10a..703a699d979c9c0912d08d2b51f5091159c08c0a 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts @@ -1,5 +1,5 @@ import { InvalidParam } from '@alfa-client/tech-shared'; -import { existsAsHtmlElement, getElementComponentFromFixtureByCss } from '@alfa-client/test-utils'; +import { existsAsHtmlElement, getElementComponentFromFixtureByCss, mock, Mock, triggerEvent } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AbstractControl, FormControl, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { expect } from '@jest/globals'; @@ -19,9 +19,14 @@ describe('UserFormRolesComponent', () => { const validationErrorTestId: string = getDataTestIdOf('rollen-error'); + let formService: Mock<UserFormService>; + beforeEach(async () => { + formService = mock(UserFormService); + await TestBed.configureTestingModule({ imports: [UserFormRolesComponent, ReactiveFormsModule, MockComponent(InfoIconComponent), MockDirective(TooltipDirective)], + providers: [{ provide: UserFormService, useValue: formService }], }).compileComponents(); fixture = TestBed.createComponent(UserFormRolesComponent); @@ -91,6 +96,25 @@ describe('UserFormRolesComponent', () => { control.setErrors(null); }); }); + + describe('handle alfa role change', () => { + it('should call form service changeAlfaRole', () => { + const formControlName: string = 'dummy'; + const value: boolean = true; + + component.handleAlfaRoleChange(formControlName, value); + + expect(formService.changeAlfaRole).toHaveBeenCalledWith(formControlName, value); + }); + }); + + describe('handle admin role change', () => { + it('should call form service removeClientRolesValidationErrors', () => { + component.handleAdminRoleChange(); + + expect(formService.removeClientRolesValidationErrors).toHaveBeenCalled(); + }); + }); }); describe('template', () => { @@ -112,5 +136,80 @@ describe('UserFormRolesComponent', () => { expect(validationErrorComponent.invalidParams).toEqual([invalidParam]); }); }); + + describe('checkbox admin', () => { + it('should call handleAdminRoleChange on inputChange emit', () => { + component.handleAdminRoleChange = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: getDataTestIdOf('checkbox-admin'), + name: 'inputChange', + data: true, + }); + + expect(component.handleAdminRoleChange).toHaveBeenCalled(); + }); + }); + + describe('checkbox datenbeauftragung', () => { + it('should call handleAdminRoleChange on inputChange emit', () => { + component.handleAdminRoleChange = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: getDataTestIdOf('checkbox-datenbeauftragung'), + name: 'inputChange', + data: true, + }); + + expect(component.handleAdminRoleChange).toHaveBeenCalled(); + }); + }); + + describe('checkbox loeschen', () => { + it('should call handleAlfaRoleChange on inputChange emit', () => { + component.handleAlfaRoleChange = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: getDataTestIdOf('checkbox-loeschen'), + name: 'inputChange', + data: true, + }); + + expect(component.handleAlfaRoleChange).toHaveBeenCalledWith(UserFormService.LOESCHEN, true); + }); + }); + + describe('checkbox user', () => { + it('should call handleAlfaRoleChange on inputChange emit', () => { + component.handleAlfaRoleChange = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: getDataTestIdOf('checkbox-user'), + name: 'inputChange', + data: true, + }); + + expect(component.handleAlfaRoleChange).toHaveBeenCalledWith(UserFormService.USER, true); + }); + }); + + describe('checkbox poststelle', () => { + it('should call handleAlfaRoleChange on inputChange emit', () => { + component.handleAlfaRoleChange = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: getDataTestIdOf('checkbox-poststelle'), + name: 'inputChange', + data: true, + }); + + expect(component.handleAlfaRoleChange).toHaveBeenCalledWith(UserFormService.POSTSTELLE, true); + }); + }); }); }); diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts index 7a8c901dab035b8a8364b9ce60c1453a7374711c..04bdc68434341f2d226954e0b0340dc56ad4a4ab 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.ts @@ -1,6 +1,6 @@ import { generateValidationErrorId, InvalidParam } from '@alfa-client/tech-shared'; import { AsyncPipe } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, inject, Input, OnInit } from '@angular/core'; import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { CheckboxEditorComponent, ValidationErrorComponent } from '@ods/component'; import { InfoIconComponent, TooltipDirective } from '@ods/system'; @@ -22,6 +22,8 @@ import { UserFormService } from '../user.formservice'; templateUrl: './user-form-roles.component.html', }) export class UserFormRolesComponent implements OnInit { + public readonly formService = inject(UserFormService); + @Input() formGroupParent: UntypedFormGroup; public invalidParams$: Observable<InvalidParam[]> = of([]); @@ -37,4 +39,12 @@ export class UserFormRolesComponent implements OnInit { tap((invalidParams: InvalidParam[]) => (this.isValid = isEmpty(invalidParams))), ); } + + public handleAlfaRoleChange(formControlName: string, value: boolean) { + this.formService.changeAlfaRole(formControlName, value); + } + + public handleAdminRoleChange() { + this.formService.removeClientRolesValidationErrors(); + } } diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.html index 064289aa47b5e6ff3e9e37656407d30cd0d52c8d..40841d6b9be5727178fd85bef19eb653e0ec2dcd 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.html @@ -1,6 +1,6 @@ <ods-button-with-spinner [stateResource]="submitStateResource$ | async" - (clickEmitter)="submit()" text="Speichern" dataTestId="save-button" + type="submit" /> diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.spec.ts index dcd4e3712eb848de5eea8e563c60987b2c310429..1ab28ef6080559135f61a47b975e96a1af384011 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.spec.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.spec.ts @@ -2,9 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { User } from '@admin-client/user-shared'; import { createStateResource, StateResource } from '@alfa-client/tech-shared'; -import { dispatchEventFromFixture, getDebugElementFromFixtureByCss, mock, Mock } from '@alfa-client/test-utils'; +import { getDebugElementFromFixtureByCss, mock, Mock } from '@alfa-client/test-utils'; import { ButtonWithSpinnerComponent } from '@ods/component'; -import { cold } from 'jest-marbles'; import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; import { getDataTestIdAttributeOf } from '../../../../../../tech-shared/test/data-test'; @@ -40,28 +39,6 @@ describe('UserFormSaveButtonComponent', () => { expect(component).toBeTruthy(); }); - describe('component', () => { - describe('submit', () => { - const userStateResource: StateResource<User> = createStateResource(createUser()); - - beforeEach(() => { - formService.submit.mockReturnValue(of(userStateResource)); - }); - - it('should call formService submit', () => { - component.submit(); - - expect(formService.submit).toHaveBeenCalled(); - }); - - it('should set submitState$', () => { - component.submit(); - - expect(component.submitStateResource$).toBeObservable(cold('(a|)', { a: userStateResource })); - }); - }); - }); - describe('template', () => { describe('button save', () => { describe('input', () => { @@ -74,16 +51,6 @@ describe('UserFormSaveButtonComponent', () => { expect(getDebugElementFromFixtureByCss(fixture, saveButton).componentInstance.stateResource).toEqual(stateResource); }); }); - - describe('output', () => { - it('should call submit', () => { - component.submit = jest.fn(); - - dispatchEventFromFixture(fixture, saveButton, 'clickEmitter'); - - expect(component.submit).toHaveBeenCalled(); - }); - }); }); }); }); diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.ts index 941904eb8d84b9c72b9483cabcfdb354026fd48e..a575d872a11a5a82623e4409832168b3f5d13e6b 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-save-button/user-form-save-button.component.ts @@ -1,10 +1,9 @@ import { User } from '@admin-client/user-shared'; import { StateResource } from '@alfa-client/tech-shared'; import { AsyncPipe } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { ButtonWithSpinnerComponent } from '@ods/component'; import { Observable } from 'rxjs'; -import { UserFormService } from '../user.formservice'; @Component({ selector: 'admin-user-form-save-button', @@ -13,11 +12,5 @@ import { UserFormService } from '../user.formservice'; templateUrl: './user-form-save-button.component.html', }) export class UserFormSaveButtonComponent { - public readonly formService = inject(UserFormService); - - public submitStateResource$: Observable<StateResource<User>>; - - public submit(): void { - this.submitStateResource$ = this.formService.submit(); - } + @Input() submitStateResource$: Observable<StateResource<User>>; } diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.html index 132dc6ba2c8eb40cfc12c0f163ba94092afca6a2..e147b63cf7d7ccc42a1e4cf4cae5b6da103d31e8 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.html @@ -23,20 +23,22 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<ods-spinner [stateResource]="userStateResource$ | async"> - <div class="max-w-[960px]" data-test-id="user-content"> - <admin-user-form-headline [isPatch]="isPatch" /> - <admin-user-form-data [formGroupParent]="formService.form" [isPatch]="isPatch" [userName]="userName" /> - <admin-user-form-roles [formGroupParent]="formService.form" /> - <admin-user-form-organisations-einheit-list - [formGroupParent]="formService.form" - [formGroupOrganisationsEinheiten]="formService.getOrganisationsEinheitenGroup()" - /> - <div class="mb-6 flex justify-between"> - <admin-user-form-save-button /> - @if (isPatch) { - <admin-delete-open-dialog-button data-test-id="delete-button-container" /> - } +<form [formGroup]="formService.form" (ngSubmit)="submit()"> + <ods-spinner [stateResource]="userStateResource$ | async"> + <div class="max-w-[960px]" data-test-id="user-content"> + <admin-user-form-headline [isPatch]="isPatch" /> + <admin-user-form-data [formGroupParent]="formService.form" [isPatch]="isPatch" [userName]="userName" /> + <admin-user-form-roles [formGroupParent]="formService.form" /> + <admin-user-form-organisations-einheit-list + [formGroupParent]="formService.form" + [formGroupOrganisationsEinheiten]="formService.getOrganisationsEinheitenGroup()" + /> + <div class="mb-6 flex justify-between"> + <admin-user-form-save-button [submitStateResource$]="submitStateResource$"/> + @if (isPatch) { + <admin-delete-open-dialog-button data-test-id="delete-button-container" /> + } + </div> </div> - </div> -</ods-spinner> + </ods-spinner> +</form> \ No newline at end of file diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.spec.ts index bbaa75605aa2bc1071ceba8302892ee5088be620..632bab5c79d13c028fd0c6d76589215007b5ab86 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.spec.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.spec.ts @@ -29,16 +29,18 @@ import { DIALOG_COMPONENT } from '@alfa-client/ui'; import { CommonModule } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { expect } from '@jest/globals'; import { ButtonWithSpinnerComponent, SpinnerComponent } from '@ods/component'; import { cold } from 'jest-marbles'; import { createUser } from 'libs/admin/user-shared/test/user'; import { MockComponent } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { getDataTestIdOf } from '../../../../../tech-shared/test/data-test'; import { UserFormDataComponent } from './user-form-data/user-form-data.component'; import { UserFormHeadlineComponent } from './user-form-headline/user-form-headline.component'; import { UserFormOrganisationsEinheitListComponent } from './user-form-organisations-einheit-list/user-form-organisations-einheit-list.component'; import { UserFormRolesComponent } from './user-form-roles/user-form-roles.component'; +import { UserFormSaveButtonComponent } from './user-form-save-button/user-form-save-button.component'; import { UserFormComponent } from './user-form.component'; import { UserFormService } from './user.formservice'; @@ -60,6 +62,7 @@ describe('UserFormComponent', () => { isInvalid: jest.fn(), form: new FormGroup({}), isPatch: jest.fn(), + submit: jest.fn(), }; await TestBed.configureTestingModule({ @@ -145,6 +148,22 @@ describe('UserFormComponent', () => { expect(component.userName).toBe(userName); }); }); + describe('submit', () => { + it('should call formservice submit', () => { + component.submit(); + + expect(formService.submit).toHaveBeenCalled(); + }); + + it('should set submit state resource', () => { + const stateResource$: Observable<StateResource<User>> = of(createStateResource(createUser())); + formService.submit.mockReturnValue(stateResource$); + + component.submit(); + + expect(component.submitStateResource$).toBe(stateResource$); + }); + }); }); describe('template', () => { @@ -204,23 +223,34 @@ describe('UserFormComponent', () => { notExistsAsHtmlElement(fixture, userContent); }); - }); - describe('admin delete button container', () => { - it('should exist', () => { - component.isPatch = true; + describe('admin save button', () => { + it('should exist with input', () => { + component.submitStateResource$ = of(createStateResource<User>(createUser())); - fixture.detectChanges(); + fixture.detectChanges(); - existsAsHtmlElement(fixture, deleteButtonContainer); + const saveButtonComponent: UserFormSaveButtonComponent = getMockComponent(fixture, UserFormSaveButtonComponent); + expect(saveButtonComponent.submitStateResource$).toBe(component.submitStateResource$); + }); }); - it('should not exist', () => { - component.isPatch = false; + describe('admin delete button container', () => { + it('should exist', () => { + component.isPatch = true; - fixture.detectChanges(); + fixture.detectChanges(); + + existsAsHtmlElement(fixture, deleteButtonContainer); + }); + + it('should not exist', () => { + component.isPatch = false; - notExistsAsHtmlElement(fixture, deleteButtonContainer); + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, deleteButtonContainer); + }); }); }); }); diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.ts index f60e03692eed409a960bdeb3b27caa64b12d1ef9..9874532168270ed3ae8558d24949fe15c9032131 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form.component.ts @@ -62,6 +62,7 @@ export class UserFormComponent implements OnInit { public userStateResource$: Observable<StateResource<User>>; public isPatch: boolean; public userName: string; + public submitStateResource$: Observable<StateResource<User>>; ngOnInit(): void { this.userStateResource$ = this.formService.get().pipe( @@ -71,4 +72,8 @@ export class UserFormComponent implements OnInit { }), ); } + + public submit(): void { + this.submitStateResource$ = this.formService.submit(); + } } diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts index caabe30992d06bce0dffefa1c988f2e405a8793a..0073a7dbefc4dcef6bd4416d0672c0b2a8cb6286 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.spec.ts @@ -42,7 +42,7 @@ import { expect } from '@jest/globals'; import { createUser } from 'libs/admin/user-shared/test/user'; import { Observable, of } from 'rxjs'; import { createUrlSegment } from '../../../../../navigation-shared/test/navigation-test-factory'; -import { singleCold, singleColdCompleted, singleHot } from '../../../../../tech-shared/test/marbles'; +import { singleCold, singleColdCompleted } from '../../../../../tech-shared/test/marbles'; import { createKeycloakHttpErrorResponse } from '../../../../keycloak-shared/src/test/keycloak'; import { createAdminOrganisationsEinheit } from '../../../../organisations-einheit-shared/src/test/organisations-einheit'; import { UserFormService } from './user.formservice'; @@ -135,7 +135,8 @@ describe('UserFormService', () => { const loadedUser: StateResource<User> = createStateResource(createUser()); beforeEach(() => { - userService.getUserById.mockReturnValue(singleHot(loadedUser)); + userService.getUserById.mockReturnValue(singleCold(loadedUser)); + service._updateAlfaRoleStates = jest.fn(); }); it('should call service to get user by id', () => { @@ -149,26 +150,24 @@ describe('UserFormService', () => { expect(response).toBeObservable(singleCold(loadedUser)); }); - }); - describe('listenToAlfaGroupChanges', () => { - it('should call handleAlfaGroupChange on initial change', () => { - service._handleAlfaGroupChange = jest.fn(); + it('should not update alfa role states if user is not loaded', () => { + userService.getUserById.mockReturnValue(of(createEmptyStateResource())); - service.listenToAlfaGroupChanges(); + service._load(id).subscribe(); - expect(service._handleAlfaGroupChange).toHaveBeenCalled(); + expect(service._updateAlfaRoleStates).not.toHaveBeenCalled(); }); + }); - it('should call handleAlfaGroupChange when value of form element changes', fakeAsync(() => { - service._handleAlfaGroupChange = jest.fn(); + describe('doAfterPatch', () => { + it('should call _updateAlfaRoleStates', () => { + service._updateAlfaRoleStates = jest.fn(); - alfaGroup.get(UserFormService.LOESCHEN).setValue(true); - - tick(); + service._doAfterPatch(createStateResource(createUser())); - expect(service._handleAlfaGroupChange).toHaveBeenCalled(); - })); + expect(service._updateAlfaRoleStates).toHaveBeenCalled(); + }); }); describe('initOrganisationsEinheiten', () => { @@ -201,10 +200,6 @@ describe('UserFormService', () => { expect(service._organisationsEinheitToGroupIdMap.get(adminOrganisationsEinheit.name)).toEqual(adminOrganisationsEinheit.id); }); - it('should set initOrganisationsEinheiten$', () => { - expect(service['_initOrganisationsEinheiten$']).toBeDefined(); - }); - it('should not throw any exception on loading state resource', () => { adminOrganisationsEinheitService.getAll.mockReturnValue(of(createLoadingStateResource())); const errorMock: any = mockWindowError(); @@ -225,79 +220,104 @@ describe('UserFormService', () => { }); }); - describe('handleAlfaGroupChange', () => { - it('should call disableUncheckedCheckboxes if any checkbox is checked', () => { - service._isAnyChecked = jest.fn().mockReturnValue(true); - service._disableUncheckedCheckboxes = jest.fn(); + describe('updateAlfaRoleStates', () => { + it('should disable alfa roles if no role is assigned', () => { + service._isAnyAlfaRoleAssigned = jest.fn().mockReturnValue(true); + service._disableAlfaRoles = jest.fn(); - service._handleAlfaGroupChange(alfaGroup); + service._updateAlfaRoleStates(); - expect(service._disableUncheckedCheckboxes).toHaveBeenCalled(); + expect(service._disableAlfaRoles).toHaveBeenCalled(); }); - it('should call enableAllCheckboxes if not any checkbox is checked', () => { - service._isAnyChecked = jest.fn().mockReturnValue(false); - service._enableAllCheckboxes = jest.fn(); + it('should enable alfa roles if any role is assigned', () => { + service._isAnyAlfaRoleAssigned = jest.fn().mockReturnValue(false); + service._enableAlfaRoles = jest.fn(); - service._handleAlfaGroupChange(alfaGroup); + service._updateAlfaRoleStates(); - expect(service._enableAllCheckboxes).toHaveBeenCalled(); + expect(service._enableAlfaRoles).toHaveBeenCalled(); }); }); - describe('isAnyChecked', () => { - it('should return false if no checkbox is checked', () => { - const result = service._isAnyChecked(alfaGroup); + describe('isAnyAlfaRoleAssigned', () => { + it('should return false if no role is assigned', () => { + const result = service._isAnyAlfaRoleAssigned(); expect(result).toBe(false); }); - it('should return true if any checkbox is checked', () => { + it('should return true if any role is assigned', () => { alfaGroup.get(UserFormService.LOESCHEN).setValue(true); - const result = service._isAnyChecked(alfaGroup); + const result = service._isAnyAlfaRoleAssigned(); expect(result).toBe(true); }); }); - describe('disableUncheckedCheckboxes', () => { - it('if control value is false then control should be disabled', () => { + describe('disableAlfaRoles', () => { + it('if role is not assigned it should be disabled', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(false); - service._disableUncheckedCheckboxes(alfaGroup); + service._disableAlfaRoles(); expect(control.disabled).toBe(true); }); - it('if control value is true then control should NOT be disabled', () => { + it('if role is assigned then it should NOT be disabled', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(true); - service._disableUncheckedCheckboxes(alfaGroup); + service._disableAlfaRoles(); expect(control.disabled).toBe(false); }); }); - describe('updateCheckboxStates', () => { - it('if control value is false then control should be disabled', () => { + describe('changeAlfaRole', () => { + it('should set control value', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); - control.setValue(false); - service._disableUncheckedCheckboxes(alfaGroup); + service.changeAlfaRole(UserFormService.LOESCHEN, true); - expect(control.disabled).toBe(true); + expect(control.value).toBe(true); + }); + + it('should call removeClientRolesValidationErrors', () => { + service.removeClientRolesValidationErrors = jest.fn(); + + service.changeAlfaRole(UserFormService.LOESCHEN, true); + + expect(service.removeClientRolesValidationErrors).toHaveBeenCalled(); + }); + + it('should call updateAlfaRoleStates', () => { + service._updateAlfaRoleStates = jest.fn(); + + service.changeAlfaRole(UserFormService.LOESCHEN, true); + + expect(service._updateAlfaRoleStates).toHaveBeenCalled(); }); }); - describe('enableAllCheckboxes', () => { + describe('removeClientRolesValidationErrors', () => { + it('should remove error on clientRoles group', () => { + roleGroup.setErrors({ error: 'Client Roles Error' }); + + service.removeClientRolesValidationErrors(); + + expect(roleGroup.errors).toBeNull(); + }); + }); + + describe('enableAlfaRoles', () => { it('if control value is true then control should be enabled', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); - const enableSpy = jest.spyOn(control, 'enable'); + const enableSpy: jest.SpyInstance = jest.spyOn(control, 'enable'); - service._enableAllCheckboxes(alfaGroup); + service._enableAlfaRoles(); expect(enableSpy).toHaveBeenCalled(); }); @@ -340,7 +360,7 @@ describe('UserFormService', () => { userService.create.mockReturnValue(of(createEmptyStateResource(true))); const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess'); - service.submit().subscribe(); + service._doSubmit().subscribe(); tick(); expect(handleOnCreateUserSuccessSpy).not.toHaveBeenCalled(); @@ -437,24 +457,6 @@ describe('UserFormService', () => { }); }); - describe('ngOnDestroy', () => { - it('should unsubscribe from initOrganisationsEinheiten$', () => { - service._initOrganisationsEinheiten$.unsubscribe = jest.fn(); - - service.ngOnDestroy(); - - expect(service._initOrganisationsEinheiten$.unsubscribe).toHaveBeenCalled(); - }); - - it('should unsubscribe from initOrganisationsEinheiten$', () => { - service._alfaGroupChanges.unsubscribe = jest.fn(); - - service.ngOnDestroy(); - - expect(service._alfaGroupChanges.unsubscribe).toHaveBeenCalled(); - }); - }); - describe('get userName', () => { it('should return form control value of userName', () => { service.form = new FormGroup({ [UserFormService.USERNAME]: new FormControl('userNameDummy') }); diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts index b0cb7867c9cd8330c274fd9ee944d16f9a5507bb..616a0e9553f766fb7f02808ec700db71f202cfe3 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts @@ -43,13 +43,13 @@ import { StateResource, } from '@alfa-client/tech-shared'; import { SnackBarService } from '@alfa-client/ui'; -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable } from '@angular/core'; import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UrlSegment } from '@angular/router'; -import { filter, Observable, Subscription, tap } from 'rxjs'; +import { filter, Observable, take, tap } from 'rxjs'; @Injectable() -export class UserFormService extends KeycloakFormService<User> implements OnDestroy { +export class UserFormService extends KeycloakFormService<User> { public static readonly FIRST_NAME: string = 'firstName'; public static readonly LAST_NAME: string = 'lastName'; public static readonly USERNAME: string = 'username'; @@ -65,9 +65,6 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest public static readonly USER: string = 'VERWALTUNG_USER'; public static readonly POSTSTELLE: string = 'VERWALTUNG_POSTSTELLE'; - _initOrganisationsEinheiten$: Subscription; - _alfaGroupChanges: Subscription; - _organisationsEinheitToGroupIdMap: Map<string, string> = new Map<string, string>(); constructor( @@ -78,9 +75,11 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest private snackBarService: SnackBarService, ) { super(); + this.init(); + } - this._initOrganisationsEinheiten$ = this._initOrganisationsEinheiten().subscribe(); - this.listenToAlfaGroupChanges(); + init() { + this._initOrganisationsEinheiten().pipe(take(1)).subscribe(); } _buildPatchConfig(url: UrlSegment[]): PatchConfig { @@ -99,35 +98,42 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest return this.userService.getUserById(id); } + _doAfterPatch(stateResource: StateResource<User>): void { + this._updateAlfaRoleStates(); + } + _initForm(): UntypedFormGroup { - return this.formBuilder.group({ - [UserFormService.FIRST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.FIRST_NAME)), - [UserFormService.LAST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.LAST_NAME)), - [UserFormService.USERNAME]: new FormControl(EMPTY_STRING, [fieldLengthValidator(UserFormService.USERNAME, 3, 255)]), - [UserFormService.EMAIL]: new FormControl(EMPTY_STRING, [ - fieldInvalidValidator(UserFormService.EMAIL, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), - ]), - [UserFormService.CLIENT_ROLES]: this.formBuilder.group( - { - [UserFormService.ADMINISTRATION_GROUP]: this.formBuilder.group({ - [UserFormService.ADMIN]: new FormControl(false), - [UserFormService.DATENBEAUFTRAGUNG]: new FormControl(false), - }), - [UserFormService.ALFA_GROUP]: this.formBuilder.group({ - [UserFormService.LOESCHEN]: new FormControl(false), - [UserFormService.USER]: new FormControl(false), - [UserFormService.POSTSTELLE]: new FormControl(false), - }), - }, - { - validators: checkBoxGroupsEmptyValidator(UserFormService.CLIENT_ROLES, [ - UserFormService.ADMINISTRATION_GROUP, - UserFormService.ALFA_GROUP, - ]), - }, - ), - [UserFormService.GROUPS]: this.formBuilder.group({}), - }); + return this.formBuilder.group( + { + [UserFormService.FIRST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.FIRST_NAME)), + [UserFormService.LAST_NAME]: new FormControl(EMPTY_STRING, fieldEmptyValidator(UserFormService.LAST_NAME)), + [UserFormService.USERNAME]: new FormControl(EMPTY_STRING, [fieldLengthValidator(UserFormService.USERNAME, 3, 255)]), + [UserFormService.EMAIL]: new FormControl(EMPTY_STRING, [ + fieldInvalidValidator(UserFormService.EMAIL, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), + ]), + [UserFormService.CLIENT_ROLES]: this.formBuilder.group( + { + [UserFormService.ADMINISTRATION_GROUP]: this.formBuilder.group({ + [UserFormService.ADMIN]: new FormControl(false), + [UserFormService.DATENBEAUFTRAGUNG]: new FormControl(false), + }), + [UserFormService.ALFA_GROUP]: this.formBuilder.group({ + [UserFormService.LOESCHEN]: new FormControl(false), + [UserFormService.USER]: new FormControl(false), + [UserFormService.POSTSTELLE]: new FormControl(false), + }), + }, + { + validators: checkBoxGroupsEmptyValidator(UserFormService.CLIENT_ROLES, [ + UserFormService.ADMINISTRATION_GROUP, + UserFormService.ALFA_GROUP, + ]), + }, + ), + [UserFormService.GROUPS]: this.formBuilder.group({}), + }, + { updateOn: 'submit' }, + ); } _initOrganisationsEinheiten(): Observable<AdminOrganisationsEinheit[]> { @@ -157,39 +163,54 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest }); } - listenToAlfaGroupChanges(): void { - const alfaGroup: UntypedFormGroup = this.getRoleGroup(UserFormService.ALFA_GROUP); - this._handleAlfaGroupChange(alfaGroup); - this._alfaGroupChanges = alfaGroup.valueChanges.subscribe(() => { - this._handleAlfaGroupChange(alfaGroup); - }); + public changeAlfaRole(formControlName: string, value: boolean) { + this.setAlfaRole(formControlName, value); + this.removeClientRolesValidationErrors(); + this._updateAlfaRoleStates(); } - _handleAlfaGroupChange(group: UntypedFormGroup): void { - const anyChecked: boolean = this._isAnyChecked(group); - if (anyChecked) { - this._disableUncheckedCheckboxes(group); + private setAlfaRole(formControlName: string, value: boolean): void { + this.getRoleGroup(UserFormService.ALFA_GROUP).get(formControlName).setValue(value, { emitEvent: false }); + } + + public removeClientRolesValidationErrors() { + this.form.get(UserFormService.CLIENT_ROLES).setErrors(null); + } + + _updateAlfaRoleStates(): void { + if (this._isAnyAlfaRoleAssigned()) { + this._disableAlfaRoles(); } else { - this._enableAllCheckboxes(group); + this._enableAlfaRoles(); } } - _isAnyChecked(group: UntypedFormGroup): boolean { - return Object.keys(group.controls).some((key) => group.controls[key].value); + _isAnyAlfaRoleAssigned(): boolean { + const alfaGroup: UntypedFormGroup = this.getRoleGroup(UserFormService.ALFA_GROUP); + return Object.keys(alfaGroup.controls).some((key) => alfaGroup.controls[key].value); } - _disableUncheckedCheckboxes(alfaGroup: UntypedFormGroup): void { - for (const control of Object.values<AbstractControl>(alfaGroup.controls)) { - if (!control.value) control.disable({ emitEvent: false }); + _disableAlfaRoles(): void { + for (const control of Object.values<AbstractControl>(this.getRoleGroup(UserFormService.ALFA_GROUP).controls)) { + if (!control.value) this.disableControl(control); } } - _enableAllCheckboxes(group: UntypedFormGroup): void { - for (const control of Object.values<AbstractControl>(group.controls)) { - control.enable({ emitEvent: false }); + _enableAlfaRoles() { + const alfaGroup: UntypedFormGroup = this.getRoleGroup(UserFormService.ALFA_GROUP); + for (const control of Object.values<AbstractControl>(alfaGroup.controls)) { + this.enableControl(control); } } + private enableControl(control: AbstractControl): void { + control.enable({ onlySelf: true }); + } + + private disableControl(control: AbstractControl): void { + if (!control.value) control.disable({ onlySelf: true }); + } + _doSubmit(): Observable<StateResource<User>> { const user: User = this._createUser(); return this._createOrSave(user).pipe( @@ -248,11 +269,6 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest return <UntypedFormGroup>this.form.get(UserFormService.CLIENT_ROLES).get(roleGroup); } - ngOnDestroy(): void { - this._initOrganisationsEinheiten$.unsubscribe(); - this._alfaGroupChanges.unsubscribe(); - } - public getUserName(): string { return this.form.get(UserFormService.USERNAME).value; } diff --git a/alfa-client/libs/design-component/src/lib/button-with-spinner/button-with-spinner.component.ts b/alfa-client/libs/design-component/src/lib/button-with-spinner/button-with-spinner.component.ts index 79e617880c423eefeb7ce3e3e41877c32154e29f..f7a4b9f1096eefd7191bf5a064c3f7e00319e49d 100644 --- a/alfa-client/libs/design-component/src/lib/button-with-spinner/button-with-spinner.component.ts +++ b/alfa-client/libs/design-component/src/lib/button-with-spinner/button-with-spinner.component.ts @@ -42,6 +42,7 @@ type ButtonVariants = VariantProps<typeof buttonVariants>; [text]="text" [variant]="variant" [size]="size" + [type]="type" [dataTestId]="dataTestId" [isLoading]="isLoading" [disabled]="disabled" @@ -59,6 +60,7 @@ export class ButtonWithSpinnerComponent { @Input() variant: ButtonVariants['variant'] = 'primary'; @Input() size: ButtonVariants['size'] = 'medium'; @Input() disabled: boolean = false; + @Input() type: 'button' | 'submit' = 'button'; @Output() public clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html index 1cc8b07470d8086854cee7b310342c8697b4eed5..cf21692584c4ef6d986a494166f22ae5cb894328 100644 --- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html +++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.html @@ -30,6 +30,7 @@ [disabled]="control.disabled" [hasError]="hasError" [ariaDescribedBy]="validationErrorId" + (inputChange)="inputChange.emit($event)" > <ods-validation-error error diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.spec.ts b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.spec.ts index cada7efb7a5dc25e643c2854438cd39f626f70a9..1b06d47d14d99aa302c659974f0c227d59d0905b 100644 --- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.spec.ts +++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.spec.ts @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { getElementFromFixture } from '@alfa-client/test-utils'; +import { getElementFromFixture, triggerEvent } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { faker } from '@faker-js/faker'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; @@ -91,4 +91,19 @@ describe('CheckboxEditorComponent', () => { }); }); }); + + describe('input change', () => { + it('should emit input change', () => { + component.inputChange.emit = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: 'ods-checkbox', + name: 'inputChange', + data: true, + }); + + expect(component.inputChange.emit).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts index bbb9f693fc54a9760f0bb33b006848b12aba6554..0a098c993e31075992c2716acf82a6bd5a507f17 100644 --- a/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/checkbox-editor/checkbox-editor.component.ts @@ -22,7 +22,7 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared'; -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { CheckboxComponent } from '@ods/system'; import { FormControlEditorAbstractComponent } from '../formcontrol-editor.abstract.component'; @@ -38,6 +38,8 @@ export class CheckboxEditorComponent extends FormControlEditorAbstractComponent @Input() inputId: string; @Input() label: string; + @Output() inputChange = new EventEmitter<boolean>(); + public readonly validationErrorId: string = generateValidationErrorId(); get hasError(): boolean { diff --git a/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.spec.ts b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6788f44db2174b42e035113bb55f2181f57ffbb --- /dev/null +++ b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.spec.ts @@ -0,0 +1,117 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormGroupDirective, ReactiveFormsModule, ValidationErrors } from '@angular/forms'; +import { FormControlEditorAbstractComponent } from '@ods/component'; +import { MockNgControl } from '../../../test/form/MockNgControl'; + +describe('FormControlEditorAbstractComponent', () => { + let component: TestComponent; + let fixture: ComponentFixture<TestComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, TestComponent], + providers: [FormGroupDirective], + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + component.control = new MockNgControl(); + fixture.detectChanges(); + }); + + describe('ngOnInit', () => { + it('should set subscription', () => { + component.ngOnInit(); + + expect(component._changesSubscription).toBeDefined(); + }); + + it('should handle field control value changes ', () => { + component.handleFieldControlValueChange = jest.fn(); + component.ngOnInit(); + + component.fieldControl.setValue('testValue'); + + expect(component.handleFieldControlValueChange).toHaveBeenCalledWith('testValue'); + }); + + it('should set subscription', () => { + component.ngOnInit(); + + expect(component._statusSubscription).toBeDefined(); + }); + + it('should set errors on statusChange', () => { + component.setErrors = jest.fn(); + component.ngOnInit(); + + component.control.control.setErrors({ required: true }); + + expect(component.setErrors).toHaveBeenCalled(); + }); + }); + + describe('writeValue', () => { + it('should set fieldControl value', () => { + const value = 'testValue'; + + component.writeValue(value); + + expect(component.fieldControl.value).toBe(value); + }); + }); + + describe('setErrors', () => { + it('should set fieldControl errors', () => { + const errors: ValidationErrors = { required: true }; + + component.control.control.setErrors(errors); + + expect(component.fieldControl.errors).toEqual(errors); + }); + + it('should set fieldControl to touched', () => { + component.fieldControl.markAsPristine(); + + component.setErrors(); + + expect(component.fieldControl.touched).toBe(true); + }); + + it('should update invalid params', () => { + component._updateInvalidParams = jest.fn(); + + component.setErrors(); + + expect(component._updateInvalidParams).toHaveBeenCalled(); + }); + }); + + describe('removeErrors', () => { + it('should remove fieldControl errors', () => { + component.fieldControl.setErrors({ fehler: 'this is an validation error' }); + + component._removeErrors(); + + expect(component.fieldControl.errors).toBeNull(); + }); + + it('should update invalid params', () => { + component._updateInvalidParams = jest.fn(); + + component._removeErrors(); + + expect(component._updateInvalidParams).toHaveBeenCalled(); + }); + }); +}); + +@Component({ + standalone: true, + template: '', + imports: [CommonModule, ReactiveFormsModule], + providers: [FormGroupDirective], +}) +class TestComponent extends FormControlEditorAbstractComponent {} diff --git a/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts index 89c0cd5fb117bc5c11c2aa254dfd810ec7616b32..1565a3646a4daf56c98fc441a1464511d8c356cc 100644 --- a/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/formcontrol-editor.abstract.component.ts @@ -31,29 +31,34 @@ import { Subscription } from 'rxjs'; }) export abstract class FormControlEditorAbstractComponent implements ControlValueAccessor, OnInit, OnDestroy { readonly fieldControl: UntypedFormControl = new UntypedFormControl(); - public onChange = (text: string | Date) => undefined; + public onChange = (value: unknown) => undefined; public onTouched = () => undefined; public invalidParams: InvalidParam[] = []; - private changesSubscr: Subscription; - private statusSubscr: Subscription; + _changesSubscription: Subscription; + _statusSubscription: Subscription; disabled: boolean = false; constructor(@Self() @Optional() public control: NgControl | null) { if (this.control) this.control.valueAccessor = this; - - this.changesSubscr = this.fieldControl.valueChanges.subscribe((val) => { - this.onChange(val); - this.setErrors(); - }); } ngOnInit(): void { - if (!this.statusSubscr && this.control) - this.statusSubscr = this.control.statusChanges.subscribe(() => { + this._changesSubscription = this.fieldControl.valueChanges.subscribe((value: unknown) => + this.handleFieldControlValueChange(value), + ); + + if (this.control) { + this._statusSubscription = this.control.statusChanges.subscribe(() => { this.setErrors(); }); + } + } + + handleFieldControlValueChange(value: unknown): void { + this.onChange(value); + this._removeErrors(); } touch(): void { @@ -62,7 +67,6 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue writeValue(text: string): void { this.fieldControl.setValue(text); - this.setErrors(); } registerOnChange(fn: (text: string | Date) => {}): void { @@ -78,18 +82,24 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue } ngOnDestroy(): void { - if (this.changesSubscr) this.changesSubscr.unsubscribe(); - if (this.statusSubscr) this.statusSubscr.unsubscribe(); + if (this._changesSubscription) this._changesSubscription.unsubscribe(); + if (this._statusSubscription) this._statusSubscription.unsubscribe(); } setErrors(): void { - if (this.control) { - this.fieldControl.setErrors(this.control.errors); - if (this.control.invalid) { - this.fieldControl.markAsTouched(); - } - this._updateInvalidParams(); - } + if (!this.control) return; + + this.fieldControl.setErrors(this.control.errors); + this.fieldControl.markAsTouched(); + + this._updateInvalidParams(); + } + + _removeErrors(): void { + if (!this.control) return; + + this.fieldControl.setErrors(null); + this._updateInvalidParams(); } _updateInvalidParams(): void { diff --git a/alfa-client/libs/design-component/test/form/MockNgControl.ts b/alfa-client/libs/design-component/test/form/MockNgControl.ts index 4d41bb1b1e4d2236d21bce9a8849007889c9010f..caad15ac47aed83a5ea74971986a00485c4130ff 100644 --- a/alfa-client/libs/design-component/test/form/MockNgControl.ts +++ b/alfa-client/libs/design-component/test/form/MockNgControl.ts @@ -28,9 +28,17 @@ import { AbstractControl, ControlValueAccessor, NgControl, UntypedFormControl } export class MockNgControl extends NgControl { valueAccessor: ControlValueAccessor | null = null; + private _control: AbstractControl = new UntypedFormControl(null); + get control(): AbstractControl { - return new UntypedFormControl(null); + return this._control; + } + + set control(ctrl: AbstractControl) { + this._control = ctrl; } viewToModelUpdate(newValue: any): void {} + + setErrors(errors: any): void {} } diff --git a/alfa-client/libs/design-system/src/lib/button/button.component.ts b/alfa-client/libs/design-system/src/lib/button/button.component.ts index 3f88a32abc1bdb41fc4c12cabc4543f7c2def3c1..68a0d753e5b89c6f542cdd2345e7a3bb5c75f6c4 100644 --- a/alfa-client/libs/design-system/src/lib/button/button.component.ts +++ b/alfa-client/libs/design-system/src/lib/button/button.component.ts @@ -104,8 +104,8 @@ export type ButtonVariants = VariantProps<typeof buttonVariants>; selector: 'ods-button', standalone: true, imports: [CommonModule, SpinnerIconComponent], - template: `<button - type="button" + template: ` <button + [type]="type" [ngClass]="buttonVariants({ size, variant, disabled: isDisabled, destructive })" [attr.aria-disabled]="isDisabled" [attr.aria-label]="text" @@ -133,6 +133,7 @@ export class ButtonComponent { @Input() variant: ButtonVariants['variant']; @Input() size: ButtonVariants['size']; @Input() spinnerSize: IconVariants['size'] = 'medium'; + @Input() type: 'button' | 'submit' = 'button'; @Output() public clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); diff --git a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.spec.ts b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.spec.ts index 508b08e5d85ee35f3ae3215b0a2229f5b416aa6c..f37e2213f3b4d85af20b2e02a509df7ce197125d 100644 --- a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.spec.ts @@ -21,6 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { triggerEvent } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CheckboxComponent } from './checkbox.component'; @@ -41,4 +42,17 @@ describe('CheckboxComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should emit input change', () => { + component.inputChange.emit = jest.fn(); + + triggerEvent({ + fixture, + elementSelector: 'input', + name: 'change', + data: { target: { checked: true } }, + }); + + expect(component.inputChange.emit).toHaveBeenCalledWith(true); + }); }); diff --git a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts index 3b3750bd3174bfaeecf2821d4dc29d9e5c6a4d4e..a90820605becdbb48e9ce846cb712b562c5857b6 100644 --- a/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/checkbox/checkbox.component.ts @@ -23,7 +23,7 @@ */ import { ConvertForDataTestPipe, EMPTY_STRING } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; @Component({ @@ -46,6 +46,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; [attr.disabled]="disabled ? true : null" [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor'" [attr.aria-describedby]="ariaDescribedBy" + (change)="inputChangeHandler($event)" /> <label class="leading-5 text-text" [attr.for]="inputId">{{ label }}</label> <svg @@ -71,4 +72,10 @@ export class CheckboxComponent { @Input() disabled: boolean = false; @Input() hasError: boolean = false; @Input() ariaDescribedBy: string = EMPTY_STRING; + + @Output() inputChange = new EventEmitter<boolean>(); + + inputChangeHandler(event: Event) { + this.inputChange.emit((event.target as HTMLInputElement).checked); + } } diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.util.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.util.ts index 201e8100c1682cafdeb57d86521d0b60b99a7b36..40f4dabc86ec168497e92529e23a1800d1649179 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.util.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.util.ts @@ -23,6 +23,7 @@ */ import { getEmbeddedResource, getUrl, hasLink, Resource, ResourceUri } from '@ngxp/rest'; import { isEqual, isNil, isNull } from 'lodash-es'; +import { delay, Observable, of, startWith } from 'rxjs'; import { HttpError } from '../tech.model'; import { encodeUrlForEmbedding, isNotNull } from '../tech.util'; @@ -54,6 +55,10 @@ export function createErrorStateResource<T>(error: HttpError): StateResource<any return { ...createEmptyStateResource<T>(), error, loaded: true }; } +export function creatDelayedEmptyStateResource<T>(): Observable<StateResource<T>> { + return of(createEmptyStateResource<T>()).pipe(delay(200), startWith(createEmptyStateResource<T>(true))); +} + export function doIfLoadingRequired(stateResource: StateResource<any>, runable: () => void): boolean { if (isLoadingRequired(stateResource)) { runable();