From 68e6ac1769a1693311637af55684a18d80a995b7 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Mon, 3 Mar 2025 22:05:36 +0100 Subject: [PATCH 01/22] OZG-7591 define keycloak errors and form service --- .../src/lib/keycloak-error-handler.ts | 21 +++++++++++++++++++ .../src/lib/keycloak-formservice.ts | 17 ++++++++++++--- .../src/lib/user.repository.ts | 7 +++---- 3 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts new file mode 100644 index 0000000000..fd424cb003 --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts @@ -0,0 +1,21 @@ +import { ProblemDetail } from '@alfa-client/tech-shared'; + +export type KeycloakErrorMapping = { [key: string]: { [key: string]: any } }; + +export type KeycloakError = ErrorMessage | ErrorRepresentation; + +export type ErrorMessage = 'User name is missing' | 'error-invalid-length' | 'error-invalid-email'; + +export interface ErrorRepresentation { + field: string; + errorMessage: ErrorMessage; + params: [string, ...any]; +} + +export function convertKeycloakErrorToProblemDetail(keycloakError: KeycloakError): ProblemDetail { + return null; +} + +export function isKeycloakError(err: any): boolean { + return err?.responseData?.errorMessage; +} 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 49a2e6c84e..0d285b15c6 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,13 +21,14 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { createEmptyStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared'; +import { createEmptyStateResource, createErrorStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; -import { first, Observable, of, tap } from 'rxjs'; +import { catchError, first, Observable, of, tap, throwError } from 'rxjs'; import * as FormUtil from './form.util'; +import { isKeycloakError, KeycloakError } from './keycloak-error-handler'; @Injectable() export abstract class KeycloakFormService<T> { @@ -70,9 +71,19 @@ export abstract class KeycloakFormService<T> { } public submit(): Observable<StateResource<T>> { - return this._doSubmit(); + return this._doSubmit().pipe( + catchError((err: any) => { + if (isKeycloakError(err)) { + this._handleKeycloakError(err); + return createErrorStateResource(err); + } + return throwError(() => 'Unknown Keycloak error. Can not handle it.'); + }), + ); } + _handleKeycloakError(err: KeycloakError): void {} + abstract _doSubmit(): Observable<StateResource<T>>; _patch(valueToPatch: T): void { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts index e5eca5be54..4072c1ab20 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts @@ -22,7 +22,7 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { ClientMapping, ClientRoles, RoleMappings, User } from '@admin-client/user-shared'; -import { createStateResource, StateResource } from '@alfa-client/tech-shared'; +import { createStateResource, ProblemDetail, StateResource } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation'; @@ -30,11 +30,10 @@ import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupR import MappingsRepresentation from '@keycloak/keycloak-admin-client/lib/defs/mappingsRepresentation'; import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; +import * as _ from 'lodash-es'; import { isNil, omit } from 'lodash-es'; import { catchError, concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs'; -import * as _ from 'lodash-es'; - @Injectable({ providedIn: 'root', }) @@ -44,7 +43,7 @@ export class UserRepository { public static readonly ALFA_CLIENT_NAME: string = 'alfa'; public static readonly ADMIN_CLIENT_NAME: string = 'admin'; - public createInKeycloak(user: User): Observable<User> { + public createInKeycloak(user: User): Observable<User | ProblemDetail> { return from(this.kcAdminClient.users.create(omit(user, 'groupIds'))).pipe( concatMap(async (response: { id: string }): Promise<{ id: string }> => { await this._updateUserRoles(response.id, user.clientRoles); -- GitLab From a6cee0963a04fe6fa511714ceee365ddc29a43b8 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Sat, 8 Mar 2025 16:30:27 +0100 Subject: [PATCH 02/22] OZG-7591 handle keycloak api errors Handle keycloak api errors on user creation. Sub task: OZG-7742 --- .../libs/admin/keycloak-shared/src/index.ts | 1 + .../src/lib/keycloak-error-handler.ts | 21 - .../src/lib/keycloak-error.model.spec.ts | 251 ++++++++++++ .../src/lib/keycloak-error.model.ts | 66 ++++ .../src/lib/keycloak-formservice.spec.ts | 365 +++++++++++++++++- .../src/lib/keycloak-formservice.ts | 110 +++++- .../src/lib/user.repository.spec.ts | 12 +- .../src/lib/user.repository.ts | 5 +- .../keycloak-shared/src/test/keycloak.ts | 23 ++ .../lib/user-form/user.formservice.spec.ts | 237 ++++++------ .../src/lib/user-form/user.formservice.ts | 54 +-- .../validation/tech.validation.messages.ts | 11 +- 12 files changed, 964 insertions(+), 192 deletions(-) delete mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts create mode 100644 alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts diff --git a/alfa-client/libs/admin/keycloak-shared/src/index.ts b/alfa-client/libs/admin/keycloak-shared/src/index.ts index 4197ca2f1a..06df4b1a3e 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/index.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/keycloak-error.model'; export * from './lib/keycloak-formservice'; export * from './lib/keycloak-token.service'; export * from './lib/keycloak.resource.service'; diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts deleted file mode 100644 index fd424cb003..0000000000 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error-handler.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ProblemDetail } from '@alfa-client/tech-shared'; - -export type KeycloakErrorMapping = { [key: string]: { [key: string]: any } }; - -export type KeycloakError = ErrorMessage | ErrorRepresentation; - -export type ErrorMessage = 'User name is missing' | 'error-invalid-length' | 'error-invalid-email'; - -export interface ErrorRepresentation { - field: string; - errorMessage: ErrorMessage; - params: [string, ...any]; -} - -export function convertKeycloakErrorToProblemDetail(keycloakError: KeycloakError): ProblemDetail { - return null; -} - -export function isKeycloakError(err: any): boolean { - return err?.responseData?.errorMessage; -} diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts new file mode 100644 index 0000000000..9c0d629e95 --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from '@jest/globals'; +import { + canHandleKeycloakError, + ErrorRepresentation, + extractErrorRepresentations, + isFieldErrorMessage, + isSingleErrorMessage, + KeycloakErrorMessage, + KeycloakErrorResponseData, + KeycloakFieldName, + KeycloakHttpErrorResponse, +} from './keycloak-error.model'; + +describe('keycloak error model', () => { + describe('extract error representations', () => { + it('should throw error', () => { + expect(() => extractErrorRepresentations({} as any)).toThrowError(); + }); + + it('should return errors', () => { + const keycloakError: KeycloakHttpErrorResponse = { + response: { + status: 400, + }, + responseData: { + errors: [ + { + field: KeycloakFieldName.USERNAME, + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + params: ['username', 3, 255], + }, + { + field: KeycloakFieldName.EMAIL, + errorMessage: KeycloakErrorMessage.INVALID_EMAIL, + params: ['email', 'f'], + }, + ], + }, + }; + + expect(extractErrorRepresentations(keycloakError)).toStrictEqual([ + { + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + params: ['username', 3, 255], + field: KeycloakFieldName.USERNAME, + } as ErrorRepresentation, + + { + errorMessage: KeycloakErrorMessage.INVALID_EMAIL, + params: ['email', 'f'], + field: KeycloakFieldName.EMAIL, + } as ErrorRepresentation, + ]); + }); + + it('should error response data', () => { + const keycloakError: KeycloakHttpErrorResponse = { + response: { + status: 400, + }, + responseData: { + field: KeycloakFieldName.USERNAME, + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + params: ['username', 3, 255], + }, + }; + + expect(extractErrorRepresentations(keycloakError)).toStrictEqual([ + { + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + params: ['username', 3, 255], + field: KeycloakFieldName.USERNAME, + } as ErrorRepresentation, + ]); + }); + }); + + describe('can handle keycloak error', () => { + const errorWithMessage: any = { + response: { + status: 400, + }, + responseData: { + errorMessage: 'User name is missing', + }, + }; + + const errorWithObject: any = { + response: { + status: 400, + }, + responseData: { + field: 'username', + errorMessage: 'error-invalid-length', + params: ['username', 3, 255], + }, + }; + + const errorWithArray: any = { + response: { + status: 400, + }, + responseData: { + errors: [ + { + field: 'username', + errorMessage: 'error-invalid-length', + params: ['username', 3, 255], + }, + { + field: 'email', + errorMessage: 'error-invalid-email', + params: ['email', 'f'], + }, + ], + }, + }; + + const errorWithoutErrorMessage: any = { + response: {}, + responseData: {}, + }; + + const errorWithoutResponseData: any = { + response: {}, + responseData: {}, + }; + + const error500: any = { + response: { + status: 500, + }, + responseData: { + errorMessage: 'internal error', + }, + }; + + it('should return true for text error message', () => { + expect(canHandleKeycloakError(errorWithMessage)).toBe(true); + }); + + it('should return true for error object', () => { + expect(canHandleKeycloakError(errorWithObject)).toBe(true); + }); + + it('should return true for error array', () => { + expect(canHandleKeycloakError(errorWithArray)).toBe(true); + }); + + it('should return false for error without error message', () => { + expect(canHandleKeycloakError(errorWithoutErrorMessage)).toBe(false); + }); + + it('should return false for error without response data', () => { + expect(canHandleKeycloakError(errorWithoutResponseData)).toBe(false); + }); + + it('should return false for 500', () => { + expect(canHandleKeycloakError(error500)).toBe(false); + }); + + describe('evaluate validation errors', () => {}); + }); + + describe('isSingleErrorMessage', () => { + it('should return false if contains field', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + field: KeycloakFieldName.USERNAME, + }; + + expect(isSingleErrorMessage(error)).toBe(false); + }); + + it('should return false if contains params', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + params: [], + }; + + expect(isSingleErrorMessage(error)).toBe(false); + }); + + it('should return false if contains errors', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + errors: [], + }; + + expect(isSingleErrorMessage(error)).toBe(false); + }); + + it('should return true', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + }; + + expect(isSingleErrorMessage(error)).toBe(true); + }); + }); + + describe('isFieldErrorMessage', () => { + it('should return false if error message missing', () => { + const error: KeycloakErrorResponseData = { + params: [], + field: KeycloakFieldName.USERNAME, + }; + + expect(isFieldErrorMessage(error)).toBe(false); + }); + + it('should return false if field missing', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + params: [], + }; + + expect(isFieldErrorMessage(error)).toBe(false); + }); + + it('should return false if params missing', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + field: KeycloakFieldName.USERNAME, + }; + + expect(isFieldErrorMessage(error)).toBe(false); + }); + + it('should return false if contains errors', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + field: KeycloakFieldName.USERNAME, + params: [], + errors: [], + }; + + expect(isFieldErrorMessage(error)).toBe(false); + }); + + it('should return true', () => { + const error: KeycloakErrorResponseData = { + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + field: KeycloakFieldName.USERNAME, + params: [], + }; + + expect(isFieldErrorMessage(error)).toBe(true); + }); + }); +}); diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts new file mode 100644 index 0000000000..356ed67c26 --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts @@ -0,0 +1,66 @@ +import { isNotNil } from '@alfa-client/tech-shared'; +import { isNil } from 'lodash-es'; + +const validationErrorStatusCodes: number[] = [400, 409]; + +export enum KeycloakFieldName { + USERNAME = 'username', + EMAIL = 'email', +} + +export enum KeycloakErrorMessage { + USERNAME_IS_MISSING = 'User name is missing', + EMAIL_EXISTS = 'User exists with same email', + USERNAME_EXISTS = 'User exists with same username', + INVALID_LENGTH = 'error-invalid-length', + INVALID_EMAIL = 'error-invalid-email', +} + +export interface ErrorRepresentation { + errorMessage?: KeycloakErrorMessage; + field?: KeycloakFieldName; + params?: any[]; +} + +export interface KeycloakHttpErrorResponse { + response: { + status: number; + }; + responseData: KeycloakErrorResponseData; +} + +export interface KeycloakErrorResponseData extends ErrorRepresentation { + errors?: ErrorRepresentation[]; +} + +export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] { + if (!canHandleKeycloakError(error)) { + throw new Error(`Cannot handle keycloak error: ${error}`); + } + const errorResponseData: KeycloakErrorResponseData = error.responseData; + return error.responseData.errors ?? [errorResponseData]; +} + +export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean { + if (!validationErrorStatusCodes.includes(err?.response?.status)) return false; + if (isNil(err?.responseData)) return false; + + if (containsErrorMessage(err)) return true; + return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage); +} + +function containsErrorMessage(err: any): boolean { + return isNotNil(err?.responseData?.errorMessage); +} + +function containsErrorArray(err: any): boolean { + return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0; +} + +export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean { + return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors); +} + +export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean { + return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors); +} 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 6fe0a332ba..87d290d1c0 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,20 +21,42 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { createEmptyStateResource, createStateResource, EMPTY_STRING, 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 { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; import { faker } from '@faker-js/faker/.'; import { createDummy, Dummy } from 'libs/tech-shared/test/dummy'; -import { singleCold, singleHot } from 'libs/tech-shared/test/marbles'; +import { singleCold, singleColdCompleted, singleHot } from 'libs/tech-shared/test/marbles'; import { createSpy, mock, Mock } from 'libs/test-utils/src/lib/mocking'; -import { Observable, of } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; import { createUrlSegment } from '../../../../navigation-shared/test/navigation-test-factory'; import { KeycloakFormService, PatchConfig } from './keycloak-formservice'; +import { expect } from '@jest/globals'; +import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages'; +import { createInvalidParam } from '../../../../tech-shared/test/error'; +import { createErrorRepresentation, createKeycloakHttpErrorResponse } from '../test/keycloak'; import * as FormUtil from './form.util'; +import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model'; + +jest.mock('./keycloak-error.model', () => ({ + ...jest.requireActual('./keycloak-error.model'), + extractErrorRepresentations: jest.fn(), + isSingleErrorMessage: jest.fn(), + isFieldErrorMessage: jest.fn(), +})); + +jest.mock('@alfa-client/tech-shared', () => ({ + ...jest.requireActual('@alfa-client/tech-shared'), + setInvalidParamValidationError: jest.fn(), +})); + +const setInvalidParamValidationErrorMock: jest.Mock = setInvalidParamValidationError as jest.Mock; +const extractErrorRepresentationsMock: jest.Mock = extractErrorRepresentations as jest.Mock; +const isSingleErrorMessageMock: jest.Mock = isSingleErrorMessage as jest.Mock; +const isFieldErrorMessageMock: jest.Mock = isFieldErrorMessage as jest.Mock; describe('KeycloakFormService', () => { let service: KeycloakFormService<Dummy>; @@ -53,6 +75,10 @@ describe('KeycloakFormService', () => { initFormSpy = createSpy(TestKeycloakFormService, '_initForm').mockReturnValue(formGroup); evaluateRouteSpy = createSpy(TestKeycloakFormService, '_evaluateRoute'); + extractErrorRepresentationsMock.mockClear(); + isSingleErrorMessageMock.mockClear(); + isFieldErrorMessageMock.mockClear(); + setInvalidParamValidationErrorMock.mockClear(); TestBed.configureTestingModule({ providers: [ @@ -186,6 +212,8 @@ describe('KeycloakFormService', () => { beforeEach(() => { service._doSubmit = jest.fn().mockReturnValue(singleHot(dummyStateResource)); + service._setValidationErrorsOnControls = jest.fn(); + service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn(); }); it('should call do submit', () => { @@ -199,6 +227,325 @@ describe('KeycloakFormService', () => { expect(submitResponse).toBeObservable(singleCold(dummyStateResource)); }); + + describe('on keycloak error', () => { + const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse(); + + beforeEach(() => { + service._doSubmit = jest.fn().mockReturnValue(throwError(() => keycloakHttpError)); + service._handleUnknownKeycloakError = jest.fn(); + }); + + it('should set validation errors on controls', () => { + const invalidParams: InvalidParam[] = [createInvalidParam()]; + service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams); + + service.submit().subscribe(); + + expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams); + }); + + it('should map keycloak error representations to invalid params', () => { + const errorRepresentation: ErrorRepresentation = createErrorRepresentation(); + extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]); + + service.submit().subscribe(); + + expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]); + }); + + it('should extract error representations', () => { + service.submit().subscribe(); + + expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError); + }); + + it('should emit empty state resource', () => { + expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); + + it('should handle unknown keycloak error', () => { + const error: Error = new Error('Fehler'); + extractErrorRepresentationsMock.mockImplementation(() => { + throw error; + }); + + service.submit().subscribe(); + + expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError); + }); + + it('should emit empty state resource exception', () => { + const error: Error = new Error('Fehler'); + extractErrorRepresentationsMock.mockImplementation(() => { + throw error; + }); + + expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); + }); + }); + + describe('set validation errors on controls', () => { + it('should set invalid param validation error', () => { + const invalidParam: InvalidParam = createInvalidParam(); + + service._setValidationErrorsOnControls([invalidParam]); + + expect(setInvalidParamValidationErrorMock).toHaveBeenCalledWith(service.form, invalidParam); + }); + }); + + describe('map keycloak error representations to invalid params', () => { + it('should map', () => { + service._mapKeycloakErrorRepresentationToInvalidParam = jest.fn(); + const errorRepresentation: ErrorRepresentation = createErrorRepresentation(); + + service._mapKeycloakErrorRepresentationsToInvalidParams([errorRepresentation]); + + expect(service._mapKeycloakErrorRepresentationToInvalidParam).toHaveBeenCalledWith(errorRepresentation); + }); + }); + + describe('map keycloak error representation to invalid param', () => { + const controlName: string = faker.word.noun(); + const invalidParam: InvalidParam = createInvalidParam(); + + beforeEach(() => { + service._evaluateControlName = jest.fn().mockReturnValue(controlName); + service._mapInvalidEmail = jest.fn().mockReturnValue(invalidParam); + service._mapInvalidLength = jest.fn().mockReturnValue(invalidParam); + service._mapEmailExists = jest.fn().mockReturnValue(invalidParam); + service._mapUsernameExists = jest.fn().mockReturnValue(invalidParam); + service._mapUserNameIsMissing = jest.fn().mockReturnValue(invalidParam); + }); + + it('should map invalid email', () => { + service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.INVALID_EMAIL, + }); + + expect(service._mapInvalidEmail).toHaveBeenCalledWith(controlName); + }); + + it('should return invalid param for invalid email', () => { + const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.INVALID_EMAIL, + }); + + expect(param).toEqual(invalidParam); + }); + + it('should map invalid length', () => { + const errorRepresentation: ErrorRepresentation = { + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + }; + + service._mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation); + + expect(service._mapInvalidLength).toHaveBeenCalledWith(controlName, errorRepresentation); + }); + + it('should return invalid param for invalid length', () => { + const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.INVALID_LENGTH, + }); + + expect(param).toEqual(invalidParam); + }); + + it('should map email exists', () => { + service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.EMAIL_EXISTS, + }); + + expect(service._mapEmailExists).toHaveBeenCalledWith(controlName); + }); + + it('should return invalid param for email exists', () => { + const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.EMAIL_EXISTS, + }); + + expect(param).toEqual(invalidParam); + }); + + it('should map username exists', () => { + service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.USERNAME_EXISTS, + }); + + expect(service._mapUsernameExists).toHaveBeenCalledWith(controlName); + }); + + it('should return invalid param for username exists', () => { + const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.USERNAME_EXISTS, + }); + + expect(param).toEqual(invalidParam); + }); + + it('should map username is missing', () => { + service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + }); + + expect(service._mapUserNameIsMissing).toHaveBeenCalledWith(controlName); + }); + + it('should return invalid param for username is missing', () => { + const param: InvalidParam = service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: KeycloakErrorMessage.USERNAME_IS_MISSING, + }); + + expect(param).toEqual(invalidParam); + }); + + it('should throw error', () => { + expect(() => + service._mapKeycloakErrorRepresentationToInvalidParam({ + ...createErrorRepresentation(), + errorMessage: 'unknown' as KeycloakErrorMessage, + }), + ).toThrowError(); + }); + }); + + describe('map invalid email', () => { + it('should return invalid param', () => { + expect(service._mapInvalidEmail('control')).toEqual({ + name: 'control', + reason: ValidationMessageCode.FIELD_INVALID, + constraintParameters: [], + value: null, + } as InvalidParam); + }); + }); + + describe('map invalid length', () => { + it('should return invalid param', () => { + expect(service._mapInvalidLength('control', { params: ['email', 1, 2] })).toEqual({ + name: 'control', + reason: ValidationMessageCode.FIELD_SIZE, + constraintParameters: [ + { name: 'min', value: '1' }, + { name: 'max', value: '2' }, + ], + value: null, + } as InvalidParam); + }); + }); + + describe('map email exists', () => { + it('should return invalid param', () => { + expect(service._mapEmailExists('control')).toEqual({ + name: 'control', + reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS, + constraintParameters: [{ name: 'value', value: 'Email-Adresse' }], + value: null, + } as InvalidParam); + }); + }); + + describe('map username exists', () => { + it('should return invalid param', () => { + expect(service._mapUsernameExists('control')).toEqual({ + name: 'control', + reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS, + constraintParameters: [{ name: 'value', value: 'Benutzername' }], + value: null, + } as InvalidParam); + }); + }); + + describe('map username is missing', () => { + it('should return invalid param', () => { + expect(service._mapUserNameIsMissing('control')).toEqual({ + name: 'control', + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + value: null, + } as InvalidParam); + }); + }); + + describe('evaluate control name', () => { + const errorRepresentation: ErrorRepresentation = createErrorRepresentation(); + + beforeEach(() => { + service._mapKeycloakErrorMessageToControlName = jest.fn(); + service._mapKeycloakFieldNameToControlName = jest.fn(); + }); + + describe('on single error message', () => { + beforeEach(() => { + isSingleErrorMessageMock.mockReturnValue(true); + isFieldErrorMessageMock.mockReturnValue(false); + }); + + it('should check if single error message', () => { + service._evaluateControlName(errorRepresentation); + + expect(isSingleErrorMessageMock).toHaveBeenCalledWith(errorRepresentation); + }); + + it('should map to control name', () => { + service._evaluateControlName(errorRepresentation); + + expect(service._mapKeycloakErrorMessageToControlName).toHaveBeenCalledWith(errorRepresentation.errorMessage); + }); + + it('should return control name', () => { + const controlName: string = faker.word.noun(); + service._mapKeycloakErrorMessageToControlName = jest.fn().mockReturnValue(controlName); + + expect(service._evaluateControlName(errorRepresentation)).toEqual(controlName); + }); + }); + + describe('on field error message', () => { + beforeEach(() => { + isSingleErrorMessageMock.mockReturnValue(false); + isFieldErrorMessageMock.mockReturnValue(true); + }); + + it('should check if field error message', () => { + service._evaluateControlName(errorRepresentation); + + expect(isFieldErrorMessageMock).toHaveBeenCalledWith(errorRepresentation); + }); + + it('should map to control name', () => { + service._evaluateControlName(errorRepresentation); + + expect(service._mapKeycloakFieldNameToControlName).toHaveBeenCalledWith(errorRepresentation.field); + }); + + it('should return control name', () => { + const controlName: string = faker.word.noun(); + service._mapKeycloakFieldNameToControlName = jest.fn().mockReturnValue(controlName); + + expect(service._evaluateControlName(errorRepresentation)).toEqual(controlName); + }); + }); + + it('should throw error', () => { + isSingleErrorMessageMock.mockReturnValue(false); + isFieldErrorMessageMock.mockReturnValue(false); + + expect(() => service._evaluateControlName(errorRepresentation)).toThrowError(); + }); }); describe('patch', () => { @@ -306,4 +653,16 @@ export class TestKeycloakFormService extends KeycloakFormService<Dummy> { _doDelete(): Observable<StateResource<Dummy>> { return TestKeycloakFormService.DELETE_OBSERVABLE(); } + + _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string { + throw new Error('Method not implemented.'); + } + + _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string { + throw new Error('Method not implemented.'); + } + + _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void { + throw new Error('Method not implemented.'); + } } 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 0d285b15c6..9fa4a9ed9d 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,14 +21,14 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { createEmptyStateResource, createErrorStateResource, isLoaded, StateResource } from '@alfa-client/tech-shared'; +import { createEmptyStateResource, InvalidParam, isLoaded, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; -import { catchError, first, Observable, of, tap, throwError } from 'rxjs'; - +import { catchError, first, Observable, of, tap } from 'rxjs'; +import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages'; import * as FormUtil from './form.util'; -import { isKeycloakError, KeycloakError } from './keycloak-error-handler'; +import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model'; @Injectable() export abstract class KeycloakFormService<T> { @@ -72,17 +72,107 @@ export abstract class KeycloakFormService<T> { public submit(): Observable<StateResource<T>> { return this._doSubmit().pipe( - catchError((err: any) => { - if (isKeycloakError(err)) { - this._handleKeycloakError(err); - return createErrorStateResource(err); + catchError((keycloakError: KeycloakHttpErrorResponse) => { + try { + this._setValidationErrorsOnControls( + this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)), + ); + } catch (error: any) { + this._handleUnknownKeycloakError(keycloakError); } - return throwError(() => 'Unknown Keycloak error. Can not handle it.'); + return of(createEmptyStateResource<T>()); }), ); } - _handleKeycloakError(err: KeycloakError): void {} + _setValidationErrorsOnControls(invalidParams: InvalidParam[]): void { + invalidParams.forEach((invalidParam: InvalidParam) => { + setInvalidParamValidationError(this.form, invalidParam); + }); + } + + _mapKeycloakErrorRepresentationsToInvalidParams(errorRepresentations: ErrorRepresentation[]): InvalidParam[] { + return errorRepresentations.map((errorRepresentation: ErrorRepresentation) => + this._mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation), + ); + } + + _mapKeycloakErrorRepresentationToInvalidParam(errorRepresentation: ErrorRepresentation): InvalidParam { + const controlName: string = this._evaluateControlName(errorRepresentation); + switch (errorRepresentation.errorMessage) { + case KeycloakErrorMessage.INVALID_EMAIL: + return this._mapInvalidEmail(controlName); + case KeycloakErrorMessage.INVALID_LENGTH: + return this._mapInvalidLength(controlName, errorRepresentation); + case KeycloakErrorMessage.EMAIL_EXISTS: + return this._mapEmailExists(controlName); + case KeycloakErrorMessage.USERNAME_EXISTS: + return this._mapUsernameExists(controlName); + case KeycloakErrorMessage.USERNAME_IS_MISSING: + return this._mapUserNameIsMissing(controlName); + default: + throw new Error(`Unknown keycloak error message ${errorRepresentation.errorMessage}`); + } + } + + _mapInvalidEmail(controlName: string): InvalidParam { + return { name: controlName, reason: ValidationMessageCode.FIELD_INVALID, constraintParameters: [], value: null }; + } + + _mapInvalidLength(controlName: string, errorRepresentation: ErrorRepresentation): InvalidParam { + return { + name: controlName, + reason: ValidationMessageCode.FIELD_SIZE, + constraintParameters: [ + { name: 'min', value: `${errorRepresentation.params[1]}` }, + { name: 'max', value: `${errorRepresentation.params[2]}` }, + ], + value: null, + }; + } + + _mapEmailExists(controlName: string): InvalidParam { + return { + name: controlName, + reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS, + constraintParameters: [{ name: 'value', value: 'Email-Adresse' }], + value: null, + }; + } + + _mapUsernameExists(controlName: string): InvalidParam { + return { + name: controlName, + reason: ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS, + constraintParameters: [{ name: 'value', value: 'Benutzername' }], + value: null, + }; + } + + _mapUserNameIsMissing(controlName: string): InvalidParam { + return { + name: controlName, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + value: null, + }; + } + + _evaluateControlName(errorRepresentation: ErrorRepresentation): string { + if (isSingleErrorMessage(errorRepresentation)) { + return this._mapKeycloakErrorMessageToControlName(errorRepresentation.errorMessage); + } + if (isFieldErrorMessage(errorRepresentation)) { + return this._mapKeycloakFieldNameToControlName(errorRepresentation.field as KeycloakFieldName); + } + throw new Error(`Cannot evaluate control name for error ${errorRepresentation}`); + } + + abstract _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string; + + abstract _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string; + + abstract _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void; abstract _doSubmit(): Observable<StateResource<T>>; diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts index afbb09bee0..ee91561216 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts @@ -25,7 +25,7 @@ import { RoleMappings, User } from '@admin-client/user-shared'; import { UserRepository } from '@admin/keycloak-shared'; import { StateResource } from '@alfa-client/tech-shared'; import { Mock, mock } from '@alfa-client/test-utils'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { faker } from '@faker-js/faker'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation'; @@ -89,16 +89,6 @@ describe('UserRepository', () => { done(); }); }); - - it('should call handleError', fakeAsync(() => { - repository._handleCreateError = jest.fn(); - kcAdminClient.users['create'] = jest.fn().mockReturnValue(throwError(() => new Error('error'))); - - repository.createInKeycloak(user).subscribe({ error: () => {} }); - tick(); - - expect(repository._handleCreateError).toHaveBeenCalled(); - })); }); describe('saveInKeycloak', () => { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts index 4072c1ab20..4dd503aadc 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts @@ -22,7 +22,7 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { ClientMapping, ClientRoles, RoleMappings, User } from '@admin-client/user-shared'; -import { createStateResource, ProblemDetail, StateResource } from '@alfa-client/tech-shared'; +import { createStateResource, StateResource } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation'; @@ -43,7 +43,7 @@ export class UserRepository { public static readonly ALFA_CLIENT_NAME: string = 'alfa'; public static readonly ADMIN_CLIENT_NAME: string = 'admin'; - public createInKeycloak(user: User): Observable<User | ProblemDetail> { + public createInKeycloak(user: User): Observable<User> { return from(this.kcAdminClient.users.create(omit(user, 'groupIds'))).pipe( concatMap(async (response: { id: string }): Promise<{ id: string }> => { await this._updateUserRoles(response.id, user.clientRoles); @@ -51,7 +51,6 @@ export class UserRepository { }), tap((response: { id: string }): void => this._sendActivationMail(response.id)), map((): User => user), - catchError((err: Error): Observable<never> => this._handleCreateError(err)), ); } diff --git a/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts b/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts new file mode 100644 index 0000000000..32fdb0792d --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts @@ -0,0 +1,23 @@ +import { + ErrorRepresentation, + KeycloakErrorMessage, + KeycloakFieldName, + KeycloakHttpErrorResponse, +} from '../lib/keycloak-error.model'; + +export function createErrorRepresentation(): ErrorRepresentation { + return { + field: KeycloakFieldName.USERNAME, + errorMessage: KeycloakErrorMessage.USERNAME_EXISTS, + params: [], + }; +} + +export function createKeycloakHttpErrorResponse(): KeycloakHttpErrorResponse { + return { + response: { + status: 400, + }, + responseData: createErrorRepresentation(), + }; +} 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 724564f208..d5340a9bfa 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 @@ -26,37 +26,33 @@ import { ROUTES } from '@admin-client/shared'; import { User, UserService } from '@admin-client/user-shared'; import { PatchConfig } from '@admin/keycloak-shared'; import { NavigationService } from '@alfa-client/navigation-shared'; -import { - createEmptyStateResource, - createLoadingStateResource, - createStateResource, - StateResource, -} from '@alfa-client/tech-shared'; +import { createEmptyStateResource, createLoadingStateResource, createStateResource, StateResource, } from '@alfa-client/tech-shared'; import { Mock, mock, mockWindowError } from '@alfa-client/test-utils'; import { SnackBarService } from '@alfa-client/ui'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AbstractControl, FormControl, FormGroup, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; import { faker } from '@faker-js/faker/locale/de'; -import { cold } from 'jest-marbles'; +import { expect } from '@jest/globals'; import { createUser } from 'libs/admin/user-shared/test/user'; -import { Observable, of, throwError } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { createUrlSegment } from '../../../../../navigation-shared/test/navigation-test-factory'; import { singleCold, singleColdCompleted, singleHot } from '../../../../../tech-shared/test/marbles'; +import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model'; +import { createKeycloakHttpErrorResponse } from '../../../../keycloak-shared/src/test/keycloak'; import { createAdminOrganisationsEinheit } from '../../../../organisations-einheit-shared/src/test/organisations-einheit'; import { UserFormService } from './user.formservice'; - import SpyInstance = jest.SpyInstance; describe('UserFormService', () => { - let formService: UserFormService; + let service: UserFormService; let roleGroup: UntypedFormGroup; let alfaGroup: UntypedFormGroup; let organisationsEinheitenGroup: UntypedFormGroup; let administrationGroup: UntypedFormGroup; - let service: Mock<UserService>; + let userService: Mock<UserService>; let adminOrganisationsEinheitService: Mock<AdminOrganisationsEinheitService>; let navigationService: Mock<NavigationService>; let snackBarService: Mock<SnackBarService>; @@ -68,7 +64,7 @@ describe('UserFormService', () => { ]); beforeEach(() => { - service = { + userService = { ...mock(UserService), refresh: jest.fn(), create: jest.fn(), @@ -88,7 +84,7 @@ describe('UserFormService', () => { providers: [ UserFormService, UntypedFormBuilder, - { provide: UserService, useValue: service }, + { provide: UserService, useValue: userService }, { provide: AdminOrganisationsEinheitService, useValue: adminOrganisationsEinheitService }, { provide: NavigationService, useValue: navigationService }, { provide: SnackBarService, useValue: snackBarService }, @@ -96,15 +92,15 @@ describe('UserFormService', () => { ], }); - formService = TestBed.inject(UserFormService); - organisationsEinheitenGroup = <UntypedFormGroup>formService.form.get(UserFormService.GROUPS); - roleGroup = <UntypedFormGroup>formService.form.get(UserFormService.CLIENT_ROLES); + service = TestBed.inject(UserFormService); + organisationsEinheitenGroup = <UntypedFormGroup>service.form.get(UserFormService.GROUPS); + roleGroup = <UntypedFormGroup>service.form.get(UserFormService.CLIENT_ROLES); alfaGroup = <UntypedFormGroup>roleGroup.get(UserFormService.ALFA_GROUP); administrationGroup = <UntypedFormGroup>roleGroup.get(UserFormService.ADMINISTRATION_GROUP); }); it('should create', () => { - expect(formService).toBeTruthy(); + expect(service).toBeTruthy(); }); describe('build patch config', () => { @@ -113,7 +109,7 @@ describe('UserFormService', () => { const benutzerUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'benutzer' }; const idUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'dummyId' }; - const patchConfig: PatchConfig = formService._buildPatchConfig([benutzerUrlSegment, idUrlSegment]); + const patchConfig: PatchConfig = service._buildPatchConfig([benutzerUrlSegment, idUrlSegment]); expect(patchConfig).toEqual({ id: 'dummyId', doPatch: true }); }); @@ -123,7 +119,7 @@ describe('UserFormService', () => { const benutzerUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'benutzer' }; const idUrlSegment: UrlSegment = <UrlSegment>{ ...createUrlSegment(), path: 'neu' }; - const patchConfig: PatchConfig = formService._buildPatchConfig([benutzerUrlSegment, idUrlSegment]); + const patchConfig: PatchConfig = service._buildPatchConfig([benutzerUrlSegment, idUrlSegment]); expect(patchConfig).toEqual({ doPatch: false }); }); @@ -135,17 +131,17 @@ describe('UserFormService', () => { const loadedUser: StateResource<User> = createStateResource(createUser()); beforeEach(() => { - service.getUserById.mockReturnValue(singleHot(loadedUser)); + userService.getUserById.mockReturnValue(singleHot(loadedUser)); }); it('should call service to get user by id', () => { - formService._load(id); + service._load(id); - expect(service.getUserById).toHaveBeenCalledWith(id); + expect(userService.getUserById).toHaveBeenCalledWith(id); }); it('should return loaded user', () => { - const response: Observable<StateResource<User>> = formService._load(id); + const response: Observable<StateResource<User>> = service._load(id); expect(response).toBeObservable(singleCold(loadedUser)); }); @@ -153,57 +149,55 @@ describe('UserFormService', () => { describe('listenToAlfaGroupChanges', () => { it('should call handleAlfaGroupChange when value of form element changes', fakeAsync(() => { - formService._handleAlfaGroupChange = jest.fn(); + service._handleAlfaGroupChange = jest.fn(); alfaGroup.get(UserFormService.LOESCHEN).setValue(true); tick(); - expect(formService._handleAlfaGroupChange).toHaveBeenCalled(); + expect(service._handleAlfaGroupChange).toHaveBeenCalled(); })); }); describe('initOrganisationsEinheiten', () => { beforeEach(() => { - formService._addOrganisationsEinheitenToForm = jest.fn(); + service._addOrganisationsEinheitenToForm = jest.fn(); }); it('should call adminOrganisationsEinheitService getAll', () => { - formService._initOrganisationsEinheiten(); + service._initOrganisationsEinheiten(); expect(adminOrganisationsEinheitService.getAll).toHaveBeenCalled(); }); it('should return resource', () => { - const result: Observable<AdminOrganisationsEinheit[]> = formService._initOrganisationsEinheiten(); + const result: Observable<AdminOrganisationsEinheit[]> = service._initOrganisationsEinheiten(); expect(result).toBeObservable(singleColdCompleted(adminOrganisationsEinheitList.resource)); }); it('should call addOrganisationsEinheitenToForm ', fakeAsync(() => { - formService._initOrganisationsEinheiten().subscribe(); + service._initOrganisationsEinheiten().subscribe(); tick(); - expect(formService._addOrganisationsEinheitenToForm).toHaveBeenCalled(); + expect(service._addOrganisationsEinheitenToForm).toHaveBeenCalled(); })); it('should save ids in map', () => { - formService._initOrganisationsEinheiten().subscribe(); + service._initOrganisationsEinheiten().subscribe(); - expect(formService._organisationsEinheitToGroupIdMap.get(adminOrganisationsEinheit.name)).toEqual( - adminOrganisationsEinheit.id, - ); + expect(service._organisationsEinheitToGroupIdMap.get(adminOrganisationsEinheit.name)).toEqual(adminOrganisationsEinheit.id); }); it('should set initOrganisationsEinheiten$', () => { - expect(formService['_initOrganisationsEinheiten$']).toBeDefined(); + expect(service['_initOrganisationsEinheiten$']).toBeDefined(); }); it('should not throw any exception on loading state resource', () => { adminOrganisationsEinheitService.getAll.mockReturnValue(of(createLoadingStateResource())); const errorMock: any = mockWindowError(); - formService._initOrganisationsEinheiten(); + service._initOrganisationsEinheiten(); expect(errorMock).not.toHaveBeenCalled(); }); @@ -211,9 +205,9 @@ describe('UserFormService', () => { describe('addOrganisationsEinheitenToForm', () => { it('should add organisationsEinheiten to form', () => { - const organisationsEinheitenGroup: UntypedFormGroup = <UntypedFormGroup>formService.form.get(UserFormService.GROUPS); + const organisationsEinheitenGroup: UntypedFormGroup = <UntypedFormGroup>service.form.get(UserFormService.GROUPS); - formService._addOrganisationsEinheitenToForm(adminOrganisationsEinheitList.resource, organisationsEinheitenGroup); + service._addOrganisationsEinheitenToForm(adminOrganisationsEinheitList.resource, organisationsEinheitenGroup); expect(organisationsEinheitenGroup.value).toEqual({ [adminOrganisationsEinheit.name]: false }); }); @@ -221,7 +215,7 @@ describe('UserFormService', () => { describe('roleValidator', () => { it('should return error if no role is selected', () => { - const result = formService.roleValidator()(roleGroup); + const result = service.roleValidator()(roleGroup); expect(result).toEqual({ atLeastOneRoleSelected: true }); }); @@ -229,7 +223,7 @@ describe('UserFormService', () => { it('should return null if at least one role is selected', () => { alfaGroup.get(UserFormService.LOESCHEN).setValue(true); - const result = formService.roleValidator()(roleGroup); + const result = service.roleValidator()(roleGroup); expect(result).toBeNull(); }); @@ -237,27 +231,27 @@ describe('UserFormService', () => { describe('handleAlfaGroupChange', () => { it('should call disableUncheckedCheckboxes if any checkbox is checked', () => { - formService._isAnyChecked = jest.fn().mockReturnValue(true); - formService._disableUncheckedCheckboxes = jest.fn(); + service._isAnyChecked = jest.fn().mockReturnValue(true); + service._disableUncheckedCheckboxes = jest.fn(); - formService._handleAlfaGroupChange(alfaGroup); + service._handleAlfaGroupChange(alfaGroup); - expect(formService._disableUncheckedCheckboxes).toHaveBeenCalled(); + expect(service._disableUncheckedCheckboxes).toHaveBeenCalled(); }); it('should call enableAllCheckboxes if not any checkbox is checked', () => { - formService._isAnyChecked = jest.fn().mockReturnValue(false); - formService._enableAllCheckboxes = jest.fn(); + service._isAnyChecked = jest.fn().mockReturnValue(false); + service._enableAllCheckboxes = jest.fn(); - formService._handleAlfaGroupChange(alfaGroup); + service._handleAlfaGroupChange(alfaGroup); - expect(formService._enableAllCheckboxes).toHaveBeenCalled(); + expect(service._enableAllCheckboxes).toHaveBeenCalled(); }); }); describe('isAnyChecked', () => { it('should return false if no checkbox is checked', () => { - const result = formService._isAnyChecked(alfaGroup); + const result = service._isAnyChecked(alfaGroup); expect(result).toBe(false); }); @@ -265,7 +259,7 @@ describe('UserFormService', () => { it('should return true if any checkbox is checked', () => { alfaGroup.get(UserFormService.LOESCHEN).setValue(true); - const result = formService._isAnyChecked(alfaGroup); + const result = service._isAnyChecked(alfaGroup); expect(result).toBe(true); }); @@ -276,7 +270,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(false); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(true); }); @@ -285,7 +279,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(true); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(false); }); @@ -296,7 +290,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(false); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(true); }); @@ -307,7 +301,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); const enableSpy = jest.spyOn(control, 'enable'); - formService._enableAllCheckboxes(alfaGroup); + service._enableAllCheckboxes(alfaGroup); expect(enableSpy).toHaveBeenCalled(); }); @@ -317,63 +311,45 @@ describe('UserFormService', () => { const user: User = createUser(); beforeEach(() => { - formService.isInvalid = jest.fn().mockReturnValue(false); - service.create.mockReturnValue(of(createStateResource(user))); - }); - - it('should return empty stateResource if form is invalid', () => { - formService.isInvalid = jest.fn().mockReturnValue(true); - - const result: Observable<StateResource<User>> = formService._doSubmit(); - - expect(result).toBeObservable(singleColdCompleted(createEmptyStateResource<User>())); + service.isInvalid = jest.fn().mockReturnValue(false); + userService.create.mockReturnValue(of(createStateResource(user))); }); it('should call createUser', () => { - formService._createUser = jest.fn(); + service._createUser = jest.fn(); - formService.submit(); + service.submit(); - expect(formService._createUser).toHaveBeenCalled(); + expect(service._createUser).toHaveBeenCalled(); }); it('should call _createOrSave', () => { - formService._createUser = jest.fn().mockReturnValue(user); - formService._createOrSave = jest.fn().mockReturnValue(of(user)); + service._createUser = jest.fn().mockReturnValue(user); + service._createOrSave = jest.fn().mockReturnValue(of(user)); - formService.submit(); + service.submit(); - expect(formService._createOrSave).toHaveBeenCalledWith(user); + expect(service._createOrSave).toHaveBeenCalledWith(user); }); it('should call handleOnCreateUserSuccess if not loading', fakeAsync(() => { - const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(formService, 'handleOnCreateUserSuccess'); + const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess'); - formService.submit().subscribe(); + service.submit().subscribe(); tick(); expect(handleOnCreateUserSuccessSpy).toHaveBeenCalled(); })); it('should not call handleOnCreateUserSuccess if loading', fakeAsync(() => { - service.create.mockReturnValue(of(createEmptyStateResource(true))); - const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(formService, 'handleOnCreateUserSuccess'); + userService.create.mockReturnValue(of(createEmptyStateResource(true))); + const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess'); - formService.submit().subscribe(); + service.submit().subscribe(); tick(); expect(handleOnCreateUserSuccessSpy).not.toHaveBeenCalled(); })); - - it('should call handleSubmitError on error', fakeAsync(() => { - service.create.mockReturnValue(throwError(() => new Error())); - const handleSubmitErrorSpy: SpyInstance = jest.spyOn(formService, 'handleSubmitError'); - - formService.submit().subscribe(); - tick(); - - expect(handleSubmitErrorSpy).toHaveBeenCalled(); - })); }); describe('createOrSave', () => { @@ -384,57 +360,50 @@ describe('UserFormService', () => { const patchConfig: PatchConfig = { id, doPatch: true }; it('should call save if patched', () => { - formService.isPatch = jest.fn().mockReturnValue(true); - formService._patchConfig = patchConfig; + service.isPatch = jest.fn().mockReturnValue(true); + service._patchConfig = patchConfig; user.groups = [adminOrganisationsEinheit.name]; - formService._organisationsEinheitToGroupIdMap.set(adminOrganisationsEinheit.name, adminOrganisationsEinheit.id); + service._organisationsEinheitToGroupIdMap.set(adminOrganisationsEinheit.name, adminOrganisationsEinheit.id); - formService._createOrSave(user); + service._createOrSave(user); - expect(service.save).toHaveBeenCalledWith({ ...user, id, groupIds: [adminOrganisationsEinheit.id] }); + expect(userService.save).toHaveBeenCalledWith({ ...user, id, groupIds: [adminOrganisationsEinheit.id] }); }); it('should call create if not patched', () => { - formService.isPatch = jest.fn().mockReturnValue(false); + service.isPatch = jest.fn().mockReturnValue(false); - formService._createOrSave(user); + service._createOrSave(user); - expect(service.create).toHaveBeenCalledWith(user); + expect(userService.create).toHaveBeenCalledWith(user); }); }); describe('handleOnCreateUserSuccess', () => { it('should show success message for create', () => { - formService.handleOnCreateUserSuccess(); + service.handleOnCreateUserSuccess(); expect(snackBarService.showInfo).toHaveBeenCalledWith('Der Benutzer wurde angelegt.'); }); it('should show success message for patch', () => { - formService.isPatch = jest.fn().mockReturnValue(true); + service.isPatch = jest.fn().mockReturnValue(true); - formService.handleOnCreateUserSuccess(); + service.handleOnCreateUserSuccess(); expect(snackBarService.showInfo).toHaveBeenCalledWith('Der Benutzer wurde gespeichert.'); }); it('should navigate back to user list', () => { - formService.handleOnCreateUserSuccess(); + service.handleOnCreateUserSuccess(); expect(navigationService.navigate).toHaveBeenCalledWith(ROUTES.BENUTZER); }); }); - describe('handleSubmitError', () => { - it('should return empty stateResource', () => { - const result = formService.handleSubmitError(); - - expect(result).toBeObservable(cold('(a|)', { a: createEmptyStateResource<User>() })); - }); - + describe('handle unknown keycloak error', () => { it('should show error message', fakeAsync(() => { - formService.handleSubmitError().subscribe(); - tick(); + service._handleUnknownKeycloakError(createKeycloakHttpErrorResponse()); expect(snackBarService.showError).toHaveBeenCalledWith('Der Benutzer konnte nicht angelegt werden.'); })); @@ -442,7 +411,7 @@ describe('UserFormService', () => { describe('getRoles', () => { it('should return no roles when none are active', () => { - const result: string[] = formService._getRoles(UserFormService.ALFA_GROUP); + const result: string[] = service._getRoles(UserFormService.ALFA_GROUP); expect(result).toEqual([]); }); @@ -450,7 +419,7 @@ describe('UserFormService', () => { it('should return poststelle role when active', () => { alfaGroup.get(UserFormService.POSTSTELLE).setValue(true); - const result: string[] = formService._getRoles(UserFormService.ALFA_GROUP); + const result: string[] = service._getRoles(UserFormService.ALFA_GROUP); expect(result).toEqual([UserFormService.POSTSTELLE]); }); @@ -458,7 +427,7 @@ describe('UserFormService', () => { describe('getActiveOrganisationsEinheiten', () => { it('should return no groups when none are active', () => { - const result: string[] = formService._getActiveOrganisationsEinheiten(); + const result: string[] = service._getActiveOrganisationsEinheiten(); expect(result).toEqual([]); }); @@ -467,7 +436,7 @@ describe('UserFormService', () => { const organisationsEinheit: AdminOrganisationsEinheit = createAdminOrganisationsEinheit(); organisationsEinheitenGroup.addControl(organisationsEinheit.name, new FormControl(true)); - const result: string[] = formService._getActiveOrganisationsEinheiten(); + const result: string[] = service._getActiveOrganisationsEinheiten(); expect(result).toEqual([organisationsEinheit.name]); }); @@ -475,21 +444,57 @@ describe('UserFormService', () => { describe('ngOnDestroy', () => { it('should unsubscribe from initOrganisationsEinheiten$', () => { - formService._initOrganisationsEinheiten$.unsubscribe = jest.fn(); + service._initOrganisationsEinheiten$.unsubscribe = jest.fn(); - formService.ngOnDestroy(); + service.ngOnDestroy(); - expect(formService._initOrganisationsEinheiten$.unsubscribe).toHaveBeenCalled(); + expect(service._initOrganisationsEinheiten$.unsubscribe).toHaveBeenCalled(); }); }); describe('get userName', () => { it('should return form control value of userName', () => { - formService.form = new FormGroup({ [UserFormService.USERNAME]: new FormControl('userNameDummy') }); + service.form = new FormGroup({ [UserFormService.USERNAME]: new FormControl('userNameDummy') }); - const userName: string = formService.getUserName(); + const userName: string = service.getUserName(); expect(userName).toBe('userNameDummy'); }); }); + + describe('map keycloak error message to control name', () => { + it('should map username is missing error message to username', () => { + expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.USERNAME_IS_MISSING)).toEqual( + UserFormService.USERNAME, + ); + }); + + it('should user name already exists error message to username', () => { + expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.USERNAME_EXISTS)).toEqual( + UserFormService.USERNAME, + ); + }); + + it('should map email exists error message to email', () => { + expect(service._mapKeycloakErrorMessageToControlName(KeycloakErrorMessage.EMAIL_EXISTS)).toEqual(UserFormService.EMAIL); + }); + + it('should throw error', () => { + expect(() => service._mapKeycloakErrorMessageToControlName('unknown' as KeycloakErrorMessage)).toThrowError(); + }); + }); + + describe('map keycloak field name to control name', () => { + it('should map username to username', () => { + expect(service._mapKeycloakFieldNameToControlName(KeycloakFieldName.USERNAME)).toEqual(UserFormService.USERNAME); + }); + + it('should map email to email', () => { + expect(service._mapKeycloakFieldNameToControlName(KeycloakFieldName.EMAIL)).toEqual(UserFormService.EMAIL); + }); + + it('should throw error', () => { + expect(() => service._mapKeycloakFieldNameToControlName('unknown' as KeycloakFieldName)).toThrowError(); + }); + }); }); 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 494673b77c..140881cfe7 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 @@ -24,22 +24,15 @@ import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared'; import { ROUTES } from '@admin-client/shared'; import { User, UserService } from '@admin-client/user-shared'; -import { KeycloakFormService, PatchConfig } from '@admin/keycloak-shared'; +import { KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig } from '@admin/keycloak-shared'; import { NavigationService } from '@alfa-client/navigation-shared'; -import { createEmptyStateResource, EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared'; +import { EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared'; import { SnackBarService } from '@alfa-client/ui'; import { Injectable, OnDestroy } from '@angular/core'; -import { - AbstractControl, - FormControl, - UntypedFormBuilder, - UntypedFormGroup, - ValidationErrors, - ValidatorFn, - Validators, -} from '@angular/forms'; +import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; import { UrlSegment } from '@angular/router'; -import { catchError, filter, Observable, of, Subscription, tap } from 'rxjs'; +import { filter, Observable, Subscription, tap } from 'rxjs'; +import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model'; @Injectable() export class UserFormService extends KeycloakFormService<User> implements OnDestroy { @@ -196,16 +189,11 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest } _doSubmit(): Observable<StateResource<User>> { - if (this.isInvalid()) { - return of(createEmptyStateResource<User>()); - } - const user: User = this._createUser(); return this._createOrSave(user).pipe( tap((state: StateResource<User>): void => { if (!state.loading) this.handleOnCreateUserSuccess(); }), - catchError((): Observable<StateResource<User>> => this.handleSubmitError()), ); } @@ -226,11 +214,6 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest this.navigationService.navigate(ROUTES.BENUTZER); } - handleSubmitError(): Observable<StateResource<User>> { - this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.'); - return of(createEmptyStateResource<User>()); - } - _createUser(): User { return { ...this._getFormValue(), @@ -270,4 +253,31 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest public getUserName(): string { return this.form.get(UserFormService.USERNAME).value; } + + _mapKeycloakErrorMessageToControlName(keycloakErrorMessage: KeycloakErrorMessage): string { + switch (keycloakErrorMessage) { + case KeycloakErrorMessage.USERNAME_IS_MISSING: + case KeycloakErrorMessage.USERNAME_EXISTS: + return UserFormService.USERNAME; + case KeycloakErrorMessage.EMAIL_EXISTS: + return UserFormService.EMAIL; + default: + throw new Error(`Cannot map keycloak error message ${keycloakErrorMessage} to control name`); + } + } + + _mapKeycloakFieldNameToControlName(keycloakFieldName: KeycloakFieldName): string { + switch (keycloakFieldName) { + case KeycloakFieldName.USERNAME: + return UserFormService.USERNAME; + case KeycloakFieldName.EMAIL: + return UserFormService.EMAIL; + default: + throw new Error(`Cannot map keycloak field name ${keycloakFieldName} to control name`); + } + } + + _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void { + this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.'); + } } diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts index accb4de667..2045e5afa6 100644 --- a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts +++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts @@ -32,21 +32,20 @@ export enum ValidationMessageCode { FIELD_INVALID = 'validation_field_invalid', FIELD_DATE_FORMAT_INVALID = 'validation_field_date_format_invalid', FIELD_ASSIGN_BEARBEITER_NOT_EXIST = 'fe_only_validation_bearbeiter_not_exist', + FIELD_VALUE_ALREADY_EXISTS = 'validation_field_value_already_exists', } export const VALIDATION_MESSAGES: { [code: string]: string } = { [ValidationMessageCode.FIELD_EMPTY]: 'Bitte {field} ausfüllen', [ValidationMessageCode.FIELD_MAX_SIZE]: '{field} darf höchstens {max} Zeichen enthalten', [ValidationMessageCode.FIELD_MIN_SIZE]: '{field} muss aus mindestens {min} Zeichen bestehen', - [ValidationMessageCode.FIELD_SIZE]: - '{field} muss mindestens {min} und darf höchstens {max} Zeichen enthalten', + [ValidationMessageCode.FIELD_SIZE]: '{field} muss mindestens {min} und darf höchstens {max} Zeichen enthalten', [ValidationMessageCode.FIELD_DATE_PAST]: 'Das Datum für {field} muss in der Zukunft liegen', [ValidationMessageCode.FIELD_INVALID]: 'Bitte {field} korrekt ausfüllen', - [ValidationMessageCode.FIELD_FILE_SIZE_EXCEEDED]: - 'Anhänge größer {max}{unit} können nicht hinzugefügt werden.', + [ValidationMessageCode.FIELD_FILE_SIZE_EXCEEDED]: 'Anhänge größer {max}{unit} können nicht hinzugefügt werden.', fe_only_validation_bearbeiter_not_exist: 'Der Bearbeiter existiert nicht', [ValidationMessageCode.FIELD_DATE_FORMAT_INVALID]: 'Geben Sie ein gültiges Datum ein', - [ValidationMessageCode.FIELD_FILE_CONTENT_TYPE_INVALID]: - 'Erlaubte Dateiendungen: pdf, jpg, png, jpeg', + [ValidationMessageCode.FIELD_FILE_CONTENT_TYPE_INVALID]: 'Erlaubte Dateiendungen: pdf, jpg, png, jpeg', + [ValidationMessageCode.FIELD_VALUE_ALREADY_EXISTS]: '{value} bereits verwendet', }; -- GitLab From 8e5b889a7cce2103286871a8e2eac47d917d385c Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Sun, 9 Mar 2025 16:12:01 +0100 Subject: [PATCH 03/22] OZG-7591 implement client validation Sub task: OZG-7743 --- .../src/lib/keycloak-formservice.spec.ts | 186 ++++++++++++++---- .../src/lib/keycloak-formservice.ts | 40 ++-- .../src/lib/user.repository.spec.ts | 11 -- .../src/lib/user.repository.ts | 3 +- .../user-form-roles.component.html | 7 +- .../user-form-roles.component.spec.ts | 64 +++++- .../user-form-roles.component.ts | 28 ++- .../lib/user-form/user.formservice.spec.ts | 23 +-- .../src/lib/user-form/user.formservice.ts | 49 ++--- .../formcontrol-editor.abstract.component.ts | 5 +- .../validation-error.component.ts | 2 +- alfa-client/libs/tech-shared/src/index.ts | 1 + .../lib/validation/tech.validators.spec.ts | 155 +++++++++++++++ .../src/lib/validation/tech.validators.ts | 81 ++++++++ 14 files changed, 537 insertions(+), 118 deletions(-) create mode 100644 alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts create mode 100644 alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts 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 87d290d1c0..98cade5917 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 @@ -24,7 +24,7 @@ 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 { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, UntypedFormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; import { faker } from '@faker-js/faker/.'; import { createDummy, Dummy } from 'libs/tech-shared/test/dummy'; @@ -212,12 +212,29 @@ describe('KeycloakFormService', () => { beforeEach(() => { service._doSubmit = jest.fn().mockReturnValue(singleHot(dummyStateResource)); - service._setValidationErrorsOnControls = jest.fn(); - service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn(); + service._processInvalidForm = jest.fn().mockReturnValue(of(createEmptyStateResource())); + service._processResponseValidationErrors = jest.fn().mockReturnValue(of(createEmptyStateResource())); + service.form.setErrors(null); + }); + + describe('on client validation error', () => { + it('should process invalid form', () => { + service.form.setErrors({ dummy: 'dummy error' }); + + service.submit().subscribe(); + + expect(service._processInvalidForm).toHaveBeenCalled(); + }); + + it('should return invalid form processing result on invalid form', () => { + service.form.setErrors({ dummy: 'dummy error' }); + + expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); }); it('should call do submit', () => { - service.submit(); + service.submit().subscribe(); expect(service._doSubmit).toHaveBeenCalled(); }); @@ -228,61 +245,160 @@ describe('KeycloakFormService', () => { expect(submitResponse).toBeObservable(singleCold(dummyStateResource)); }); - describe('on keycloak error', () => { + describe('on server validation error', () => { const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse(); beforeEach(() => { service._doSubmit = jest.fn().mockReturnValue(throwError(() => keycloakHttpError)); - service._handleUnknownKeycloakError = jest.fn(); }); - it('should set validation errors on controls', () => { - const invalidParams: InvalidParam[] = [createInvalidParam()]; - service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams); - + it('should process response validation errors', () => { service.submit().subscribe(); - expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams); + expect(service._processResponseValidationErrors).toHaveBeenCalledWith(keycloakHttpError); }); - it('should map keycloak error representations to invalid params', () => { - const errorRepresentation: ErrorRepresentation = createErrorRepresentation(); - extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]); + it('should return processing response validation errors result', () => { + expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); + }); + }); - service.submit().subscribe(); + describe('process invalid form', () => { + beforeEach(() => { + service._showValidationErrorForAllInvalidControls = jest.fn(); + }); - expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]); - }); + it('should show validation errors on all invalid controls', () => { + service._processInvalidForm(); - it('should extract error representations', () => { - service.submit().subscribe(); + expect(service._showValidationErrorForAllInvalidControls).toHaveBeenCalledWith(service.form); + }); + + it('should return emit state resource', () => { + expect(service._processInvalidForm()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); + }); + + describe('process response validation errors', () => { + const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse(); - expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError); + beforeEach(() => { + service._handleUnknownKeycloakError = jest.fn(); + service._setValidationErrorsOnControls = jest.fn(); + service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn(); + }); + + it('should set validation errors on controls', () => { + const invalidParams: InvalidParam[] = [createInvalidParam()]; + service._mapKeycloakErrorRepresentationsToInvalidParams = jest.fn().mockReturnValue(invalidParams); + + service._processResponseValidationErrors(keycloakHttpError); + + expect(service._setValidationErrorsOnControls).toHaveBeenCalledWith(invalidParams); + }); + + it('should map keycloak error representations to invalid params', () => { + const errorRepresentation: ErrorRepresentation = createErrorRepresentation(); + extractErrorRepresentationsMock.mockReturnValue([errorRepresentation]); + + service._processResponseValidationErrors(keycloakHttpError); + + expect(service._mapKeycloakErrorRepresentationsToInvalidParams).toHaveBeenCalledWith([errorRepresentation]); + }); + + it('should extract error representations', () => { + service._processResponseValidationErrors(keycloakHttpError); + + expect(extractErrorRepresentationsMock).toHaveBeenCalledWith(keycloakHttpError); + }); + + it('should emit empty state resource', () => { + expect(service._processResponseValidationErrors(keycloakHttpError)).toBeObservable( + singleColdCompleted(createEmptyStateResource()), + ); + }); + + it('should handle unknown keycloak error', () => { + const error: Error = new Error('Fehler'); + extractErrorRepresentationsMock.mockImplementation(() => { + throw error; }); - it('should emit empty state resource', () => { - expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + service._processResponseValidationErrors(keycloakHttpError); + + expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError); + }); + + it('should emit empty state resource exception', () => { + const error: Error = new Error('Fehler'); + extractErrorRepresentationsMock.mockImplementation(() => { + throw error; }); - it('should handle unknown keycloak error', () => { - const error: Error = new Error('Fehler'); - extractErrorRepresentationsMock.mockImplementation(() => { - throw error; - }); + expect(service._processResponseValidationErrors(keycloakHttpError)).toBeObservable( + singleColdCompleted(createEmptyStateResource()), + ); + }); + }); + + describe('show validation errors on all invalid controls', () => { + it('should update value and validity on invalid control', () => { + const control: AbstractControl = new FormControl(); + control.setErrors({ dummy: 'error' }); + const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity'); - service.submit().subscribe(); + service._showValidationErrorForAllInvalidControls(control); - expect(service._handleUnknownKeycloakError).toHaveBeenCalledWith(keycloakHttpError); + expect(spy).toHaveBeenCalled(); + }); + + it('should update value and validity form invalid control from group', () => { + const control: AbstractControl = new FormControl(); + control.setErrors({ dummy: 'error' }); + const spy: jest.SpyInstance = jest.spyOn(service, '_showValidationErrorForAllInvalidControls'); + const group: UntypedFormGroup = new FormGroup({ + someControl: control, }); - it('should emit empty state resource exception', () => { - const error: Error = new Error('Fehler'); - extractErrorRepresentationsMock.mockImplementation(() => { - throw error; - }); + service._showValidationErrorForAllInvalidControls(group); - expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + expect(spy).toHaveBeenCalledWith(control); + }); + + it('should update value and validity on invalid control from group', () => { + const control: AbstractControl = new FormControl(); + control.setErrors({ dummy: 'error' }); + const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity'); + const group: UntypedFormGroup = new FormGroup({ + someControl: control, }); + + service._showValidationErrorForAllInvalidControls(group); + + expect(spy).toHaveBeenCalled(); + }); + + it('should update value and validity form invalid control from array', () => { + const control: AbstractControl = new FormControl(); + control.setErrors({ dummy: 'error' }); + const spy: jest.SpyInstance = jest.spyOn(service, '_showValidationErrorForAllInvalidControls'); + const array: FormArray = new FormArray([control]); + + service._showValidationErrorForAllInvalidControls(array); + + expect(spy).toHaveBeenCalledWith(control); + }); + + it('should update value and validity on invalid control from group', () => { + const control: AbstractControl = new FormControl(); + control.setErrors({ dummy: 'error' }); + const spy: jest.SpyInstance = jest.spyOn(control, 'updateValueAndValidity'); + const array: FormArray = new FormArray([control]); + + service._showValidationErrorForAllInvalidControls(array); + + expect(spy).toHaveBeenCalled(); }); }); 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 9fa4a9ed9d..a949187cc4 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 @@ -23,7 +23,7 @@ */ import { createEmptyStateResource, InvalidParam, isLoaded, setInvalidParamValidationError, StateResource, } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { AbstractControl, FormArray, FormBuilder, FormGroup } from '@angular/forms'; import { ActivatedRoute, UrlSegment } from '@angular/router'; import { catchError, first, Observable, of, tap } from 'rxjs'; import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages'; @@ -71,20 +71,38 @@ export abstract class KeycloakFormService<T> { } public submit(): Observable<StateResource<T>> { + if (this.form.invalid) { + return this._processInvalidForm(); + } return this._doSubmit().pipe( - catchError((keycloakError: KeycloakHttpErrorResponse) => { - try { - this._setValidationErrorsOnControls( - this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)), - ); - } catch (error: any) { - this._handleUnknownKeycloakError(keycloakError); - } - return of(createEmptyStateResource<T>()); - }), + catchError((keycloakError: KeycloakHttpErrorResponse) => this._processResponseValidationErrors(keycloakError)), ); } + _processInvalidForm(): Observable<StateResource<T>> { + this._showValidationErrorForAllInvalidControls(this.form); + return of(createEmptyStateResource<T>()); + } + + _processResponseValidationErrors(keycloakError: KeycloakHttpErrorResponse): Observable<StateResource<T>> { + try { + this._setValidationErrorsOnControls( + this._mapKeycloakErrorRepresentationsToInvalidParams(extractErrorRepresentations(keycloakError)), + ); + } catch (error: any) { + console.error(error); + this._handleUnknownKeycloakError(keycloakError); + } + return of(createEmptyStateResource<T>()); + } + + _showValidationErrorForAllInvalidControls(control: AbstractControl): void { + if (control.invalid) control.updateValueAndValidity(); + if (control instanceof FormGroup || control instanceof FormArray) { + Object.values(control.controls).forEach((control) => this._showValidationErrorForAllInvalidControls(control)); + } + } + _setValidationErrorsOnControls(invalidParams: InvalidParam[]): void { invalidParams.forEach((invalidParam: InvalidParam) => { setInvalidParamValidationError(this.form, invalidParam); diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts index ee91561216..471464d347 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts @@ -34,7 +34,6 @@ import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/rol import { Users } from '@keycloak/keycloak-admin-client/lib/resources/users'; import { cold } from 'jest-marbles'; import { omit, times } from 'lodash-es'; -import { throwError } from 'rxjs'; import { createUser } from '../../../user-shared/test/user'; import { UserFormService } from '../../../user/src/lib/user-form/user.formservice'; @@ -120,16 +119,6 @@ describe('UserRepository', () => { done(); }); }); - - it('should call handleError', fakeAsync(() => { - repository._handleSaveError = jest.fn(); - kcAdminClient.users['update'] = jest.fn().mockReturnValue(throwError(() => new Error('error'))); - - repository.saveInKeycloak(user).subscribe({ error: () => {} }); - tick(); - - expect(repository._handleSaveError).toHaveBeenCalled(); - })); }); describe('updateUserGroups', () => { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts index 4dd503aadc..cf471180b7 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts @@ -32,7 +32,7 @@ import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/rol import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import * as _ from 'lodash-es'; import { isNil, omit } from 'lodash-es'; -import { catchError, concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs'; +import { concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -62,7 +62,6 @@ export class UserRepository { await this._updateUserGroups(user.id, user.groupIds); }), map((): User => user), - catchError((err: Error): Observable<never> => this._handleSaveError(err)), ); } 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 a8931fbcb6..ddf6dce385 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 @@ -1,4 +1,9 @@ -<h2 class="heading-2 mt-4">Rollen für OZG-Cloud</h2> +<h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2> +<ods-validation-error + [invalidParams]="invalidParams$ | async" + label="Rollen" + data-test-id="rollen-error" +></ods-validation-error> <div [formGroup]="formGroupParent"> <div [formGroupName]="UserFormService.CLIENT_ROLES" class="mb-8 flex gap-56"> <div [formGroupName]="UserFormService.ADMINISTRATION_GROUP" class="flex flex-col gap-2"> 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 b27337b278..bc6c77336f 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,14 +1,24 @@ +import { InvalidParam } from '@alfa-client/tech-shared'; +import { existsAsHtmlElement, getElementComponentFromFixtureByCss } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; +import { AbstractControl, FormControl, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; +import { expect } from '@jest/globals'; import { InfoIconComponent, TooltipDirective } from '@ods/system'; import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component'; +import { getDataTestIdOf } from '../../../../../../tech-shared/test/data-test'; +import { createInvalidParam } from '../../../../../../tech-shared/test/error'; import { createUserFormGroup } from '../../../../test/form'; +import { UserFormService } from '../user.formservice'; import { UserFormRolesComponent } from './user-form-roles.component'; describe('UserFormRolesComponent', () => { let component: UserFormRolesComponent; let fixture: ComponentFixture<UserFormRolesComponent>; + const validationErrorTestId: string = getDataTestIdOf('rollen-error'); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [UserFormRolesComponent, ReactiveFormsModule, MockComponent(InfoIconComponent), MockDirective(TooltipDirective)], @@ -23,4 +33,56 @@ describe('UserFormRolesComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('component', () => { + describe('ngOnInit', () => { + it('should emit invalid params on invalid control', (done) => { + const invalidParam: InvalidParam = createInvalidParam(); + const error: any = { dummy: invalidParam }; + const control: AbstractControl = new FormControl(); + component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control }); + + component.ngOnInit(); + component.invalidParams$.subscribe((result) => { + expect(result).toEqual([invalidParam]); + done(); + }); + control.setErrors(error); + }); + + it('should emit empty array on valid control', (done) => { + const control: AbstractControl = new FormControl(); + component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control }); + + component.ngOnInit(); + component.invalidParams$.subscribe((result) => { + expect(result).toEqual([]); + done(); + }); + + control.setErrors(null); + }); + }); + }); + + describe('template', () => { + describe('validation error', () => { + it('should exists', () => { + existsAsHtmlElement(fixture, validationErrorTestId); + }); + + it('should have inputs', () => { + const invalidParam: InvalidParam = createInvalidParam(); + component.invalidParams$ = of([invalidParam]); + + fixture.detectChanges(); + const validationErrorComponent: ValidationErrorComponent = getElementComponentFromFixtureByCss( + fixture, + validationErrorTestId, + ); + + expect(validationErrorComponent.invalidParams).toEqual([invalidParam]); + }); + }); + }); }); 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 c7d46e948f..9c6608e1f5 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,17 +1,37 @@ -import { Component, Input } from '@angular/core'; -import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; +import { InvalidParam } from '@alfa-client/tech-shared'; +import { AsyncPipe } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { CheckboxEditorComponent } from '@ods/component'; import { InfoIconComponent, TooltipDirective } from '@ods/system'; +import { map, Observable, of } from 'rxjs'; +import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component'; import { UserFormService } from '../user.formservice'; @Component({ selector: 'admin-user-form-roles', standalone: true, - imports: [CheckboxEditorComponent, ReactiveFormsModule, TooltipDirective, InfoIconComponent], + imports: [ + CheckboxEditorComponent, + ReactiveFormsModule, + TooltipDirective, + InfoIconComponent, + ValidationErrorComponent, + AsyncPipe, + ], templateUrl: './user-form-roles.component.html', }) -export class UserFormRolesComponent { +export class UserFormRolesComponent implements OnInit { @Input() formGroupParent: UntypedFormGroup; + public invalidParams$: Observable<InvalidParam[]> = of([]); + public readonly UserFormService = UserFormService; + + ngOnInit(): void { + const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES]; + this.invalidParams$ = control.statusChanges.pipe( + map((status: FormControlStatus) => (status === 'INVALID' ? Object.values(control.errors) : [])), + ); + } } 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 d5340a9bfa..fe90cad9af 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 @@ -213,22 +213,6 @@ describe('UserFormService', () => { }); }); - describe('roleValidator', () => { - it('should return error if no role is selected', () => { - const result = service.roleValidator()(roleGroup); - - expect(result).toEqual({ atLeastOneRoleSelected: true }); - }); - - it('should return null if at least one role is selected', () => { - alfaGroup.get(UserFormService.LOESCHEN).setValue(true); - - const result = service.roleValidator()(roleGroup); - - expect(result).toBeNull(); - }); - }); - describe('handleAlfaGroupChange', () => { it('should call disableUncheckedCheckboxes if any checkbox is checked', () => { service._isAnyChecked = jest.fn().mockReturnValue(true); @@ -318,7 +302,7 @@ describe('UserFormService', () => { it('should call createUser', () => { service._createUser = jest.fn(); - service.submit(); + service._doSubmit().subscribe(); expect(service._createUser).toHaveBeenCalled(); }); @@ -327,7 +311,7 @@ describe('UserFormService', () => { service._createUser = jest.fn().mockReturnValue(user); service._createOrSave = jest.fn().mockReturnValue(of(user)); - service.submit(); + service._doSubmit().subscribe(); expect(service._createOrSave).toHaveBeenCalledWith(user); }); @@ -335,8 +319,7 @@ describe('UserFormService', () => { it('should call handleOnCreateUserSuccess if not loading', fakeAsync(() => { const handleOnCreateUserSuccessSpy: SpyInstance = jest.spyOn(service, 'handleOnCreateUserSuccess'); - service.submit().subscribe(); - tick(); + service._doSubmit().subscribe(); expect(handleOnCreateUserSuccessSpy).toHaveBeenCalled(); })); 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 140881cfe7..fb4974b4cb 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 @@ -24,15 +24,14 @@ import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared'; import { ROUTES } from '@admin-client/shared'; import { User, UserService } from '@admin-client/user-shared'; -import { KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig } from '@admin/keycloak-shared'; +import { KeycloakErrorMessage, KeycloakFieldName, KeycloakFormService, KeycloakHttpErrorResponse, PatchConfig, } from '@admin/keycloak-shared'; import { NavigationService } from '@alfa-client/navigation-shared'; -import { EMPTY_STRING, isLoaded, mapToResource, StateResource } from '@alfa-client/tech-shared'; +import { checkBoxGroupsEmptyValidator, EMPTY_STRING, fieldEmptyValidator, fieldInvalidValidator, fieldLengthValidator, isLoaded, mapToResource, StateResource, } from '@alfa-client/tech-shared'; import { SnackBarService } from '@alfa-client/ui'; import { Injectable, OnDestroy } from '@angular/core'; -import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; +import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UrlSegment } from '@angular/router'; import { filter, Observable, Subscription, tap } from 'rxjs'; -import { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model'; @Injectable() export class UserFormService extends KeycloakFormService<User> implements OnDestroy { @@ -86,10 +85,12 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest _initForm(): UntypedFormGroup { return this.formBuilder.group({ - [UserFormService.FIRST_NAME]: new FormControl(EMPTY_STRING, Validators.required), - [UserFormService.LAST_NAME]: new FormControl(EMPTY_STRING, Validators.required), - [UserFormService.USERNAME]: new FormControl(EMPTY_STRING, Validators.required), - [UserFormService.EMAIL]: new FormControl(EMPTY_STRING, [Validators.required]), + [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({ @@ -102,33 +103,17 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest [UserFormService.POSTSTELLE]: new FormControl(false), }), }, - { validators: this.roleValidator() }, + { + validators: checkBoxGroupsEmptyValidator(UserFormService.CLIENT_ROLES, [ + UserFormService.ADMINISTRATION_GROUP, + UserFormService.ALFA_GROUP, + ]), + }, ), [UserFormService.GROUPS]: this.formBuilder.group({}), }); } - roleValidator(): ValidatorFn { - return (control: AbstractControl<UntypedFormGroup>): ValidationErrors | null => { - const rolesGroups: UntypedFormGroup = control.value; - - if (this.anyRoleSelected(rolesGroups)) { - return null; - } - - return { atLeastOneRoleSelected: true }; - }; - } - - private anyRoleSelected(rolesGroups: UntypedFormGroup): boolean { - for (const subGroup of Object.values(rolesGroups)) { - if (Object.values(subGroup).includes(true)) { - return true; - } - } - return false; - } - _initOrganisationsEinheiten(): Observable<AdminOrganisationsEinheit[]> { const organisationsEinheitenGroup: UntypedFormGroup = this.getOrganisationsEinheitenGroup(); return this.adminOrganisationsEinheitService.getAll().pipe( @@ -278,6 +263,8 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest } _handleUnknownKeycloakError(error: KeycloakHttpErrorResponse): void { - this.snackBarService.showError('Der Benutzer konnte nicht angelegt werden.'); + this.snackBarService.showError( + this.isPatch() ? 'Der Benutzer konnte nicht gespeichert werden.' : 'Der Benutzer konnte nicht angelegt werden.', + ); } } 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 0e77b51c10..89c0cd5fb1 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 @@ -64,12 +64,15 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue this.fieldControl.setValue(text); this.setErrors(); } + registerOnChange(fn: (text: string | Date) => {}): void { this.onChange = fn; } + registerOnTouched(fn: () => {}): void { this.onTouched = fn; } + setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } @@ -83,9 +86,9 @@ export abstract class FormControlEditorAbstractComponent implements ControlValue if (this.control) { this.fieldControl.setErrors(this.control.errors); if (this.control.invalid) { - this._updateInvalidParams(); this.fieldControl.markAsTouched(); } + this._updateInvalidParams(); } } diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts index 8ae6e949f8..8844b8075d 100644 --- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { InvalidParam, getMessageForInvalidParam } from '@alfa-client/tech-shared'; +import { getMessageForInvalidParam, InvalidParam } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { ErrorMessageComponent } from '@ods/system'; diff --git a/alfa-client/libs/tech-shared/src/index.ts b/alfa-client/libs/tech-shared/src/index.ts index be9ebae3ab..9220218eb9 100644 --- a/alfa-client/libs/tech-shared/src/index.ts +++ b/alfa-client/libs/tech-shared/src/index.ts @@ -62,3 +62,4 @@ export * from './lib/service/formservice.abstract'; export * from './lib/tech.model'; export * from './lib/tech.util'; export * from './lib/validation/tech.validation.util'; +export * from './lib/validation/tech.validators'; diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts new file mode 100644 index 0000000000..00ef101f7d --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.spec.ts @@ -0,0 +1,155 @@ +import { + checkBoxGroupsEmptyValidator, + fieldEmptyValidator, + fieldInvalidValidator, + fieldLengthValidator, + InvalidParam, +} from '@alfa-client/tech-shared'; +import { AbstractControl, FormControl, UntypedFormGroup } from '@angular/forms'; +import { expect } from '@jest/globals'; +import { ValidationMessageCode } from './tech.validation.messages'; + +describe('Tech Validators', () => { + describe('field empty validator', () => { + it('should return null', () => { + const control: AbstractControl = new FormControl('test'); + + expect(fieldEmptyValidator('')(control)).toBeNull(); + }); + + it('should return invalid param', () => { + const control: AbstractControl = new FormControl(null); + + expect(fieldEmptyValidator('')(control)).toEqual({ + [ValidationMessageCode.FIELD_EMPTY]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + } as InvalidParam, + }); + }); + }); + + describe('field invalid validator', () => { + it('should return null', () => { + const control: AbstractControl = new FormControl('test'); + + expect(fieldInvalidValidator('', /^test$/)(control)).toBeNull(); + }); + + it('should return invalid param', () => { + const control: AbstractControl = new FormControl('test2'); + + expect(fieldInvalidValidator('', /^test$/)(control)).toEqual({ + [ValidationMessageCode.FIELD_INVALID]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_INVALID, + constraintParameters: [], + } as InvalidParam, + }); + }); + }); + + describe('field length validator', () => { + it('should return null', () => { + const control: AbstractControl = new FormControl('test'); + + expect(fieldLengthValidator('', 1, 5)(control)).toBeNull(); + }); + + it('should return invalid param with min length', () => { + const control: AbstractControl = new FormControl('t'); + + expect(fieldLengthValidator('', 2, 5)(control)).toEqual({ + [ValidationMessageCode.FIELD_SIZE]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_SIZE, + constraintParameters: [ + { name: 'min', value: '2' }, + { name: 'max', value: '5' }, + ], + } as InvalidParam, + }); + }); + + it('should return invalid param with max length', () => { + const control: AbstractControl = new FormControl('test test test'); + + expect(fieldLengthValidator('', 2, 5)(control)).toEqual({ + [ValidationMessageCode.FIELD_SIZE]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_SIZE, + constraintParameters: [ + { name: 'min', value: '2' }, + { name: 'max', value: '5' }, + ], + } as InvalidParam, + }); + }); + }); + + describe('check box group empty validator', () => { + it('should return null', () => { + const control: UntypedFormGroup = new UntypedFormGroup({ + group1: new UntypedFormGroup({ + check1: new FormControl(true), + check2: new FormControl(false), + }), + group2: new UntypedFormGroup({ + check1: new FormControl(false), + check2: new FormControl(false), + }), + }); + + expect(checkBoxGroupsEmptyValidator('', ['group1', 'group2'])(control)).toBeNull(); + }); + + it('should return invalid param', () => { + const control: UntypedFormGroup = new UntypedFormGroup({ + group1: new UntypedFormGroup({ + check1: new FormControl(false), + check2: new FormControl(false), + }), + group2: new UntypedFormGroup({ + check1: new FormControl(false), + check2: new FormControl(false), + }), + }); + + expect(checkBoxGroupsEmptyValidator('', ['group1', 'group2'])(control)).toEqual({ + [ValidationMessageCode.FIELD_EMPTY]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + } as InvalidParam, + }); + }); + + it('should return invalid param and ignore group', () => { + const control: UntypedFormGroup = new UntypedFormGroup({ + group1: new UntypedFormGroup({ + check1: new FormControl(false), + check2: new FormControl(false), + }), + group2: new UntypedFormGroup({ + check1: new FormControl(true), + check2: new FormControl(false), + }), + }); + + expect(checkBoxGroupsEmptyValidator('', ['group1'])(control)).toEqual({ + [ValidationMessageCode.FIELD_EMPTY]: { + name: '', + value: null, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + } as InvalidParam, + }); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts new file mode 100644 index 0000000000..798a98d8eb --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validators.ts @@ -0,0 +1,81 @@ +import { InvalidParam, isNotEmpty, isNotNil } from '@alfa-client/tech-shared'; +import { AbstractControl, UntypedFormGroup, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { ValidationMessageCode } from './tech.validation.messages'; + +export function fieldEmptyValidator(controlName: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (isNotEmpty(control.value)) return null; + return { + [ValidationMessageCode.FIELD_EMPTY]: { + name: controlName, + value: null, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + } as InvalidParam, + }; + }; +} + +export function fieldInvalidValidator(controlName: string, regex: RegExp): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (regex.test(control.value)) return null; + return { + [ValidationMessageCode.FIELD_INVALID]: { + name: controlName, + value: null, + reason: ValidationMessageCode.FIELD_INVALID, + constraintParameters: [], + } as InvalidParam, + }; + }; +} + +export function fieldLengthValidator(controlName: string, min: number, max: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + if (isNotEmpty(control.value) && value.length >= min && value.length <= max) return null; + return { + [ValidationMessageCode.FIELD_SIZE]: { + name: controlName, + value: null, + reason: ValidationMessageCode.FIELD_SIZE, + constraintParameters: [ + { name: 'min', value: min.toString() }, + { name: 'max', value: max.toString() }, + ], + } as InvalidParam, + }; + }; +} + +/** + * A simplified version of validation subgroups of check boxes. Could be extended for more advanced forms. + * It only looks up direct children of check box groups. + */ +export function checkBoxGroupsEmptyValidator(controlName: string, groupNames: string[]): ValidatorFn { + return (control: AbstractControl<UntypedFormGroup>): ValidationErrors | null => { + const group: UntypedFormGroup = control as UntypedFormGroup; + const found: boolean = groupNames + .filter((groupName: string) => _existsUntypedFormSubGroup(group, groupName)) + .map((groupName: string) => group.controls[groupName] as UntypedFormGroup) + .map(_isAtLeastOneChecked) + .reduce((a: boolean, b: boolean) => a || b); + if (found) return null; + return { + [ValidationMessageCode.FIELD_EMPTY]: { + name: controlName, + value: null, + reason: ValidationMessageCode.FIELD_EMPTY, + constraintParameters: [], + } as InvalidParam, + }; + }; +} + +export function _existsUntypedFormSubGroup(group: UntypedFormGroup, subGroupName: string): boolean { + return isNotNil(group.controls[subGroupName]) && group.controls[subGroupName] instanceof UntypedFormGroup; +} + +export function _isAtLeastOneChecked(group: UntypedFormGroup): boolean { + return Object.values(group.value).findIndex((isChecked: boolean) => isChecked) > -1; +} -- GitLab From 7a5cf8d076f1fceded38742aebae1cf182cfb385 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Sun, 9 Mar 2025 20:33:11 +0100 Subject: [PATCH 04/22] OZG-7591 add data test ids for children Sub task: OZG-7805 --- .../user-form-data/user-form-data.component.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html index 430a3fde77..97ad637c9a 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-data/user-form-data.component.html @@ -1,8 +1,8 @@ <div [formGroup]="formGroupParent"> <div class="mb-4 grid gap-4 xl:grid-cols-2"> - <ods-text-editor [formControlName]="UserFormService.FIRST_NAME" [isRequired]="true" label="Vorname" /> - <ods-text-editor [formControlName]="UserFormService.LAST_NAME" [isRequired]="true" label="Nachname" /> - <ods-text-editor [formControlName]="UserFormService.USERNAME" [isRequired]="true" label="Benutzername" /> - <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" /> + <ods-text-editor [formControlName]="UserFormService.FIRST_NAME" [isRequired]="true" label="Vorname" dataTestId="firstName" /> + <ods-text-editor [formControlName]="UserFormService.LAST_NAME" [isRequired]="true" label="Nachname" dataTestId="lastName"/> + <ods-text-editor [formControlName]="UserFormService.USERNAME" [isRequired]="true" label="Benutzername" dataTestId="username"/> + <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" dataTestId="email"/> </div> </div> -- GitLab From 3363163e6f45c003e1dec0ca9447e8236cb9e64d Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Sun, 9 Mar 2025 20:33:58 +0100 Subject: [PATCH 05/22] OZG-7591 refresh list resource on error Fixes issue with not loaded user list after an error occurs. Sub task: OZG-7805 --- .../src/lib/keycloak.resource.service.spec.ts | 26 ++++++++++++++----- .../src/lib/keycloak.resource.service.ts | 6 ++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts index 5a91a0813f..329afd1ac1 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.spec.ts @@ -21,17 +21,16 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { faker } from '@faker-js/faker'; import { cold } from 'jest-marbles'; -import { StateResource, createEmptyStateResource, createStateResource } from 'libs/tech-shared/src/lib/resource/resource.util'; -import { Dummy, createDummy } from 'libs/tech-shared/test/dummy'; +import * as ResourceUtil from 'libs/tech-shared/src/lib/resource/resource.util'; +import { createEmptyStateResource, createStateResource, StateResource } from 'libs/tech-shared/src/lib/resource/resource.util'; +import { createDummy, Dummy } from 'libs/tech-shared/test/dummy'; import { singleCold } from 'libs/tech-shared/test/marbles'; -import { Observable, of } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; import { KeycloakResourceService } from './keycloak.resource.service'; -import * as ResourceUtil from 'libs/tech-shared/src/lib/resource/resource.util'; - describe('KeycloakResourceService', () => { let service: KeycloakResourceService<unknown>; @@ -200,6 +199,21 @@ describe('KeycloakResourceService', () => { expect(service.refresh).toHaveBeenCalled(); })); + + it('should call refresh on error', () => { + service.refresh = jest.fn(); + + service.refreshAfterFirstEmit(throwError(() => new Error('dummy'))).subscribe(); + + expect(service.refresh).toHaveBeenCalled(); + }); + + it('should rethrow error', () => { + const error = new Error('dummy'); + service.refresh = jest.fn(); + + expect(service.refreshAfterFirstEmit(throwError(() => error))).toBeObservable(cold('#', null, error)); + }); }); describe('setLoadingInStateResource', () => { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts index ae74c73ee2..8bd44d4254 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak.resource.service.ts @@ -22,7 +22,7 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { createEmptyStateResource, createStateResource, doIfLoadingRequired, StateResource } from '@alfa-client/tech-shared'; -import { BehaviorSubject, first, map, Observable, startWith, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, catchError, first, map, Observable, startWith, switchMap, tap, throwError } from 'rxjs'; export abstract class KeycloakResourceService<T> { readonly stateResource: BehaviorSubject<StateResource<T[]>> = new BehaviorSubject(createEmptyStateResource()); @@ -78,6 +78,10 @@ export abstract class KeycloakResourceService<T> { return action.pipe( first(), tap((): void => this.refresh()), + catchError((err) => { + this.refresh(); + return throwError(() => err); + }), ); } -- GitLab From 1f7523b8e89122018411e69ff433278672bcaa2f Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Sun, 9 Mar 2025 20:34:28 +0100 Subject: [PATCH 06/22] OZG-7591 add e2e tests for validation Sub task: OZG-7805 --- .../benutzer/benutzer.e2e.component.ts | 43 ++++++++- .../benutzer_rollen/benutzer-anlegen.cy.ts | 93 +++++++++++++++++++ .../src/helper/benutzer/benutzer.executor.ts | 20 ++++ .../src/helper/benutzer/benutzer.helper.ts | 20 ++++ .../src/helper/benutzer/benutzer.verifier.ts | 57 +++++++++++- 5 files changed, 228 insertions(+), 5 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index 673ea2c475..2d2fdbc886 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -93,12 +93,21 @@ export class BenutzerListItemE2EComponent { } export class BenutzerE2EComponent { + public static readonly VORNAME_EMPTY_ERROR: string = 'Bitte Vorname ausfüllen'; + public static readonly NACHNAME_EMPTY_ERROR: string = 'Bitte Nachname ausfüllen'; + public static readonly BENUTZERNAME_SIZE_ERROR: string = + 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten'; + public static readonly EMAIL_INVALID_ERROR: string = 'Bitte E-Mail korrekt ausfüllen'; + public static readonly ROLLEN_EMPTY_ERROR: string = 'Bitte Rollen ausfüllen'; + public static readonly BENUTZER_NAME_EXISTS: string = 'Benutzername bereits verwendet'; + public static readonly EMAIL_EXISTS: string = 'Email-Adresse bereits verwendet'; + private readonly headline: string = 'benutzer-form-headline'; - private readonly userVorname: string = 'Vorname-text-input'; - private readonly userNachname: string = 'Nachname-text-input'; - private readonly userBenutzername: string = 'Benutzername-text-input'; - private readonly userMail: string = 'E-Mail-text-input'; + private readonly userVorname: string = 'firstName-text-input'; + private readonly userNachname: string = 'lastName-text-input'; + private readonly userBenutzername: string = 'username-text-input'; + private readonly userMail: string = 'email-text-input'; private readonly adminCheckboxLabel: string = 'Admin'; private readonly loeschenCheckboxLabel: string = 'Löschen'; @@ -111,6 +120,12 @@ export class BenutzerE2EComponent { private readonly saveButton: string = 'save-button'; private readonly deleteButton: string = 'delete-button'; + private readonly firstNameValidationError: string = 'firstName-text-editor-error'; + private readonly lastNameValidationError: string = 'lastName-text-editor-error'; + private readonly usernameValidationError: string = 'username-text-editor-error'; + private readonly emailValidationError: string = 'email-text-editor-error'; + private readonly rollenValidationError: string = 'rollen-error'; + public getHeadline(): Cypress.Chainable<Element> { return cy.getTestElement(this.headline); } @@ -166,6 +181,26 @@ export class BenutzerE2EComponent { public getDeleteButton(): Cypress.Chainable<Element> { return cy.getTestElement(this.deleteButton); } + + public getFirstNameValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.firstNameValidationError); + } + + public getLastNameValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.lastNameValidationError); + } + + public getUsernameValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.usernameValidationError); + } + + public getEmailValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.emailValidationError); + } + + public getRollenValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.rollenValidationError); + } } export class BenutzerDeleteDialogE2EComponent { diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts index e2cbf35c6a..d5b90ab701 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { E2EBenutzerHelper } from 'apps/admin-e2e/src/helper/benutzer/benutzer.helper'; import { E2EBenutzerVerifier } from 'apps/admin-e2e/src/helper/benutzer/benutzer.verifier'; import { getCypressEnv, interceptWithResponse, waitOfInterceptor } from 'apps/admin-e2e/src/support/cypress-helper'; @@ -62,4 +63,96 @@ describe('Benutzer anlegen', () => { benutzerHelper.deleteBenutzer(newUser.username); benutzerVerifier.verifyUserNotInList(newUser.username); }); + + describe('client validation errors', () => { + it('should show all if empty form', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyMissingVornameError(); + benutzerVerifier.verifyLastNameError(); + benutzerVerifier.verifyUsernameSizeError(); + benutzerVerifier.verifyEmailInvalidError(); + benutzerVerifier.verifyRollenError(); + }); + + it('should hide Vorname validation error', () => { + benutzerHelper.editVorname('Max'); + benutzerVerifier.verifyNoFirstNameValidationError(); + benutzerVerifier.verifyLastNameError(); + benutzerVerifier.verifyUsernameSizeError(); + benutzerVerifier.verifyEmailInvalidError(); + benutzerVerifier.verifyRollenError(); + }); + + it('should hide Nachname validation error', () => { + benutzerHelper.editNachname('Mustermann'); + benutzerVerifier.verifyNoFirstNameValidationError(); + benutzerVerifier.verifyNoLastNameValidationError(); + benutzerVerifier.verifyUsernameSizeError(); + benutzerVerifier.verifyEmailInvalidError(); + benutzerVerifier.verifyRollenError(); + }); + + it('should hide Benutzername validation error', () => { + benutzerHelper.editBenutzername('Max'); + benutzerVerifier.verifyNoFirstNameValidationError(); + benutzerVerifier.verifyNoLastNameValidationError(); + benutzerVerifier.verifyNoUsernameSizeValidationError(); + benutzerVerifier.verifyEmailInvalidError(); + benutzerVerifier.verifyRollenError(); + }); + + it('should hide Email validation error', () => { + benutzerHelper.editEmail('max@max.local'); + benutzerVerifier.verifyNoFirstNameValidationError(); + benutzerVerifier.verifyNoLastNameValidationError(); + benutzerVerifier.verifyNoUsernameSizeValidationError(); + benutzerVerifier.verifyNoEmailInvalidValidationError(); + benutzerVerifier.verifyRollenError(); + }); + + it('should hide Rollen validation error', () => { + benutzerHelper.addUserRole(); + benutzerVerifier.verifyNoFirstNameValidationError(); + benutzerVerifier.verifyNoLastNameValidationError(); + benutzerVerifier.verifyNoUsernameSizeValidationError(); + benutzerVerifier.verifyNoEmailInvalidValidationError(); + benutzerVerifier.verifyNoRollenValidationError(); + }); + }); + + describe('server validation errors', () => { + it('should create user', () => { + benutzerHelper.openNewBenutzerPage(); + + benutzerHelper.addBenutzer(newUser); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyUserInList(newUser); + }); + + it('should show validation error on existing email', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.editBenutzer(newUser); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyEmailExistsError(); + }); + + it('should show validation error on existing username', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.editBenutzer({ ...newUser, email: faker.internet.email() + '.local' }); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyUserExistsError(); + }); + + it('should remove benutzer', () => { + benutzerHelper.openBenutzerPage(newUser.username); + benutzerHelper.deleteBenutzer(newUser.username); + + benutzerVerifier.verifyUserNotInList(newUser.username); + }); + }); }); diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts index f0ed339c63..03638bd1b1 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.executor.ts @@ -36,6 +36,26 @@ export class E2EBenutzerExecutor { this.modifyOrganisationsEinheiten(user.organisationseinheiten); } + public modifyVorname(vorname: string): void { + this.benutzerPage.getVornameInput().type(vorname); + } + + public modifyNachname(nachname: string): void { + this.benutzerPage.getNachnameInput().type(nachname); + } + + public modifyBenutzername(benutzername: string): void { + this.benutzerPage.getBenutzernameInput().type(benutzername); + } + + public modifyEmail(email: string): void { + this.benutzerPage.getMailInput().type(email); + } + + public checkUserRole(): void { + this.benutzerPage.getUserCheckbox().getRoot().click(); + } + public modifyOrganisationsEinheiten(organisationseinheiten: OrganisationsEinheitE2E[]): void { organisationseinheiten.forEach((name: string) => this.benutzerPage.getOrganisationsEinheitCheckbox(name).click()); } diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts index 2b06766322..ae7758ac0a 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts @@ -33,6 +33,26 @@ export class E2EBenutzerHelper { this.modifyBenutzer(user); } + public editVorname(vorname: string): void { + this.executer.modifyVorname(vorname); + } + + public editNachname(nachname: string): void { + this.executer.modifyNachname(nachname); + } + + public editBenutzername(username: string): void { + this.executer.modifyBenutzername(username); + } + + public editEmail(email: string): void { + this.executer.modifyEmail(email); + } + + public addUserRole(): void { + this.executer.checkUserRole(); + } + private modifyBenutzer(user: AdminUserE2E): void { this.executer.modifyBenutzer(user); } diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index be750e3dfb..b1e61a5344 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -4,7 +4,7 @@ import { BenutzerListItemE2EComponent, } from '../../components/benutzer/benutzer.e2e.component'; import { AdminUserE2E } from '../../model/util'; -import { contains, exist, notExist } from '../../support/cypress.util'; +import { contains, exist, notContains, notExist } from '../../support/cypress.util'; import { AlfaRollen } from '../../support/user-util'; export class E2EBenutzerVerifier { @@ -48,4 +48,59 @@ export class E2EBenutzerVerifier { private getBenutzerItem(userName: string): BenutzerListItemE2EComponent { return this.benutzerListPage.getItem(userName); } + + public verifyMissingVornameError(): void { + exist(this.benutzerPage.getFirstNameValidationError()); + contains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR); + } + + public verifyLastNameError(): void { + exist(this.benutzerPage.getLastNameValidationError()); + contains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR); + } + + public verifyUsernameSizeError(): void { + exist(this.benutzerPage.getUsernameValidationError()); + contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR); + } + + public verifyUserExistsError(): void { + exist(this.benutzerPage.getUsernameValidationError()); + contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZER_NAME_EXISTS); + } + + public verifyEmailInvalidError(): void { + exist(this.benutzerPage.getEmailValidationError()); + contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR); + } + + public verifyEmailExistsError(): void { + exist(this.benutzerPage.getEmailValidationError()); + contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_EXISTS); + } + + public verifyRollenError(): void { + exist(this.benutzerPage.getRollenValidationError()); + contains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR); + } + + public verifyNoFirstNameValidationError(): void { + notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR); + } + + public verifyNoLastNameValidationError(): void { + notContains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR); + } + + public verifyNoUsernameSizeValidationError(): void { + notContains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR); + } + + public verifyNoEmailInvalidValidationError(): void { + notContains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR); + } + + public verifyNoRollenValidationError(): void { + notContains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR); + } } -- GitLab From 403539dd44a7c6c3a993d102314a33e783e64ab3 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 17:39:55 +0100 Subject: [PATCH 07/22] OZG-7591 move code from model to util CR --- .../src/lib/keycloak-error.model.ts | 37 +---------------- ...el.spec.ts => keycloak-error.util.spec.ts} | 10 +++-- .../src/lib/keycloak-error.util.ts | 40 +++++++++++++++++++ .../src/lib/keycloak-formservice.spec.ts | 7 ++-- .../src/lib/keycloak-formservice.ts | 3 +- 5 files changed, 53 insertions(+), 44 deletions(-) rename alfa-client/libs/admin/keycloak-shared/src/lib/{keycloak-error.model.spec.ts => keycloak-error.util.spec.ts} (99%) create mode 100644 alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts index 356ed67c26..87bdce2f2d 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts @@ -1,7 +1,4 @@ -import { isNotNil } from '@alfa-client/tech-shared'; -import { isNil } from 'lodash-es'; - -const validationErrorStatusCodes: number[] = [400, 409]; +export const KEYCLOAK_VALIDATION_ERROR_STATUS_CODES: number[] = [400, 409]; export enum KeycloakFieldName { USERNAME = 'username', @@ -32,35 +29,3 @@ export interface KeycloakHttpErrorResponse { export interface KeycloakErrorResponseData extends ErrorRepresentation { errors?: ErrorRepresentation[]; } - -export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] { - if (!canHandleKeycloakError(error)) { - throw new Error(`Cannot handle keycloak error: ${error}`); - } - const errorResponseData: KeycloakErrorResponseData = error.responseData; - return error.responseData.errors ?? [errorResponseData]; -} - -export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean { - if (!validationErrorStatusCodes.includes(err?.response?.status)) return false; - if (isNil(err?.responseData)) return false; - - if (containsErrorMessage(err)) return true; - return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage); -} - -function containsErrorMessage(err: any): boolean { - return isNotNil(err?.responseData?.errorMessage); -} - -function containsErrorArray(err: any): boolean { - return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0; -} - -export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean { - return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors); -} - -export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean { - return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors); -} diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts similarity index 99% rename from alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts rename to alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts index 9c0d629e95..29c1aea25a 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from '@jest/globals'; import { - canHandleKeycloakError, ErrorRepresentation, - extractErrorRepresentations, - isFieldErrorMessage, - isSingleErrorMessage, KeycloakErrorMessage, KeycloakErrorResponseData, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model'; +import { + canHandleKeycloakError, + extractErrorRepresentations, + isFieldErrorMessage, + isSingleErrorMessage, +} from './keycloak-error.util'; describe('keycloak error model', () => { describe('extract error representations', () => { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts new file mode 100644 index 0000000000..5f8cf30cad --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts @@ -0,0 +1,40 @@ +import { + ErrorRepresentation, + KEYCLOAK_VALIDATION_ERROR_STATUS_CODES, + KeycloakErrorResponseData, + KeycloakHttpErrorResponse, +} from '@admin/keycloak-shared'; +import { isNotNil } from '@alfa-client/tech-shared'; +import { isNil } from 'lodash-es'; + +export function extractErrorRepresentations(error: KeycloakHttpErrorResponse): ErrorRepresentation[] { + if (!canHandleKeycloakError(error)) { + throw new Error(`Cannot handle keycloak error: ${error}`); + } + const errorResponseData: KeycloakErrorResponseData = error.responseData; + return error.responseData.errors ?? [errorResponseData]; +} + +export function canHandleKeycloakError(err: KeycloakHttpErrorResponse): boolean { + if (!KEYCLOAK_VALIDATION_ERROR_STATUS_CODES.includes(err?.response?.status)) return false; + if (isNil(err?.responseData)) return false; + + if (containsErrorMessage(err)) return true; + return containsErrorArray(err) && isNotNil(err.responseData.errors[0]?.errorMessage); +} + +function containsErrorMessage(err: any): boolean { + return isNotNil(err?.responseData?.errorMessage); +} + +function containsErrorArray(err: any): boolean { + return isNotNil(err?.responseData?.errors) && err.responseData.errors.length > 0; +} + +export function isSingleErrorMessage(err: KeycloakErrorResponseData): boolean { + return isNotNil(err.errorMessage) && isNil(err.field) && isNil(err.params) && isNil(err.errors); +} + +export function isFieldErrorMessage(err: KeycloakErrorResponseData): boolean { + return isNotNil(err.errorMessage) && isNotNil(err.field) && isNotNil(err.params) && isNil(err.errors); +} 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 98cade5917..038187decc 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 @@ -39,10 +39,11 @@ import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validatio import { createInvalidParam } from '../../../../tech-shared/test/error'; import { createErrorRepresentation, createKeycloakHttpErrorResponse } from '../test/keycloak'; import * as FormUtil from './form.util'; -import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model'; +import { ErrorRepresentation, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model'; +import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util'; -jest.mock('./keycloak-error.model', () => ({ - ...jest.requireActual('./keycloak-error.model'), +jest.mock('./keycloak-error.util', () => ({ + ...jest.requireActual('./keycloak-error.util'), extractErrorRepresentations: jest.fn(), isSingleErrorMessage: jest.fn(), isFieldErrorMessage: jest.fn(), 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 a949187cc4..e1856df17c 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 @@ -28,7 +28,8 @@ import { ActivatedRoute, UrlSegment } from '@angular/router'; import { catchError, first, Observable, of, tap } from 'rxjs'; import { ValidationMessageCode } from '../../../../tech-shared/src/lib/validation/tech.validation.messages'; import * as FormUtil from './form.util'; -import { ErrorRepresentation, extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse, } from './keycloak-error.model'; +import { ErrorRepresentation, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model'; +import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util'; @Injectable() export abstract class KeycloakFormService<T> { -- GitLab From 83f4f89668a4918b659373c9f794c19e1319ef8c Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 17:43:16 +0100 Subject: [PATCH 08/22] OZG-7591 add export to index CR --- .../lib/user-form/user-form-roles/user-form-roles.component.ts | 3 +-- alfa-client/libs/design-component/src/index.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) 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 9c6608e1f5..70a8bdfdcc 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 @@ -2,10 +2,9 @@ import { InvalidParam } from '@alfa-client/tech-shared'; import { AsyncPipe } from '@angular/common'; import { Component, Input, OnInit } from '@angular/core'; import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; -import { CheckboxEditorComponent } from '@ods/component'; +import { CheckboxEditorComponent, ValidationErrorComponent } from '@ods/component'; import { InfoIconComponent, TooltipDirective } from '@ods/system'; import { map, Observable, of } from 'rxjs'; -import { ValidationErrorComponent } from '../../../../../../design-component/src/lib/form/validation-error/validation-error.component'; import { UserFormService } from '../user.formservice'; @Component({ diff --git a/alfa-client/libs/design-component/src/index.ts b/alfa-client/libs/design-component/src/index.ts index 9eec827c3a..27e3b6381b 100644 --- a/alfa-client/libs/design-component/src/index.ts +++ b/alfa-client/libs/design-component/src/index.ts @@ -31,6 +31,7 @@ export * from './lib/form/formcontrol-editor.abstract.component'; export * from './lib/form/single-file-upload-editor/single-file-upload-editor.component'; export * from './lib/form/text-editor/text-editor.component'; export * from './lib/form/textarea-editor/textarea-editor.component'; +export * from './lib/form/validation-error/validation-error.component'; export * from './lib/open-dialog-button/open-dialog-button.component'; export * from './lib/routing-button/routing-button.component'; export * from './lib/spinner/spinner.component'; -- GitLab From c94bc2e511fbdd9cfd215852b3170e9ac704dace Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 17:51:31 +0100 Subject: [PATCH 09/22] OZG-7591 organize imports CR --- .../src/lib/user-form/user.formservice.spec.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 61786fdc9f..d8d3db22fd 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 @@ -24,7 +24,7 @@ import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared'; import { ROUTES } from '@admin-client/shared'; import { User, UserService } from '@admin-client/user-shared'; -import { PatchConfig } from '@admin/keycloak-shared'; +import { KeycloakErrorMessage, KeycloakFieldName, PatchConfig } from '@admin/keycloak-shared'; import { NavigationService } from '@alfa-client/navigation-shared'; import { createEmptyStateResource, createLoadingStateResource, createStateResource, StateResource, } from '@alfa-client/tech-shared'; import { Mock, mock, mockWindowError } from '@alfa-client/test-utils'; @@ -38,7 +38,6 @@ 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 { KeycloakErrorMessage, KeycloakFieldName } from '../../../../keycloak-shared/src/lib/keycloak-error.model'; import { createKeycloakHttpErrorResponse } from '../../../../keycloak-shared/src/test/keycloak'; import { createAdminOrganisationsEinheit } from '../../../../organisations-einheit-shared/src/test/organisations-einheit'; import { UserFormService } from './user.formservice'; @@ -149,11 +148,11 @@ describe('UserFormService', () => { describe('listenToAlfaGroupChanges', () => { it('should call handleAlfaGroupChange on initial change', () => { - formService._handleAlfaGroupChange = jest.fn(); + service._handleAlfaGroupChange = jest.fn(); - formService.listenToAlfaGroupChanges(); + service.listenToAlfaGroupChanges(); - expect(formService._handleAlfaGroupChange).toHaveBeenCalled(); + expect(service._handleAlfaGroupChange).toHaveBeenCalled(); }); it('should call handleAlfaGroupChange when value of form element changes', fakeAsync(() => { @@ -443,11 +442,11 @@ describe('UserFormService', () => { }); it('should unsubscribe from initOrganisationsEinheiten$', () => { - formService._alfaGroupChanges.unsubscribe = jest.fn(); + service._alfaGroupChanges.unsubscribe = jest.fn(); - formService.ngOnDestroy(); + service.ngOnDestroy(); - expect(formService._alfaGroupChanges.unsubscribe).toHaveBeenCalled(); + expect(service._alfaGroupChanges.unsubscribe).toHaveBeenCalled(); }); }); -- GitLab From 976e9c799e30f90b68dbc2c3f1f8614421e221b2 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 18:31:34 +0100 Subject: [PATCH 10/22] OZG-7591 move validation messages CR --- .../benutzer/benutzer.e2e.component.ts | 9 ------- .../src/helper/benutzer/benutzer.verifier.ts | 25 ++++++++++--------- .../admin-e2e/src/model/benutzer.messages.ts | 9 +++++++ 3 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index 2d2fdbc886..fc463536e8 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -93,15 +93,6 @@ export class BenutzerListItemE2EComponent { } export class BenutzerE2EComponent { - public static readonly VORNAME_EMPTY_ERROR: string = 'Bitte Vorname ausfüllen'; - public static readonly NACHNAME_EMPTY_ERROR: string = 'Bitte Nachname ausfüllen'; - public static readonly BENUTZERNAME_SIZE_ERROR: string = - 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten'; - public static readonly EMAIL_INVALID_ERROR: string = 'Bitte E-Mail korrekt ausfüllen'; - public static readonly ROLLEN_EMPTY_ERROR: string = 'Bitte Rollen ausfüllen'; - public static readonly BENUTZER_NAME_EXISTS: string = 'Benutzername bereits verwendet'; - public static readonly EMAIL_EXISTS: string = 'Email-Adresse bereits verwendet'; - private readonly headline: string = 'benutzer-form-headline'; private readonly userVorname: string = 'firstName-text-input'; diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index eea2afce0e..afd3f0539a 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -3,6 +3,7 @@ import { BenutzerListE2EComponent, BenutzerListItemE2EComponent, } from '../../components/benutzer/benutzer.e2e.component'; +import { BenutzerValidationMessages } from '../../model/benutzer.messages'; import { AdminUserE2E } from '../../model/util'; import { contains, exist, notBeEnabled, notContains, notExist } from '../../support/cypress.util'; import { AlfaRollen } from '../../support/user-util'; @@ -70,56 +71,56 @@ export class E2EBenutzerVerifier { public verifyMissingVornameError(): void { exist(this.benutzerPage.getFirstNameValidationError()); - contains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR); + contains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); } public verifyLastNameError(): void { exist(this.benutzerPage.getLastNameValidationError()); - contains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR); + contains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } public verifyUsernameSizeError(): void { exist(this.benutzerPage.getUsernameValidationError()); - contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR); + contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); } public verifyUserExistsError(): void { exist(this.benutzerPage.getUsernameValidationError()); - contains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZER_NAME_EXISTS); + contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); } public verifyEmailInvalidError(): void { exist(this.benutzerPage.getEmailValidationError()); - contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR); + contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); } public verifyEmailExistsError(): void { exist(this.benutzerPage.getEmailValidationError()); - contains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_EXISTS); + contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS); } public verifyRollenError(): void { exist(this.benutzerPage.getRollenValidationError()); - contains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR); + contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } public verifyNoFirstNameValidationError(): void { - notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerE2EComponent.VORNAME_EMPTY_ERROR); + notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); } public verifyNoLastNameValidationError(): void { - notContains(this.benutzerPage.getLastNameValidationError(), BenutzerE2EComponent.NACHNAME_EMPTY_ERROR); + notContains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } public verifyNoUsernameSizeValidationError(): void { - notContains(this.benutzerPage.getUsernameValidationError(), BenutzerE2EComponent.BENUTZERNAME_SIZE_ERROR); + notContains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); } public verifyNoEmailInvalidValidationError(): void { - notContains(this.benutzerPage.getEmailValidationError(), BenutzerE2EComponent.EMAIL_INVALID_ERROR); + notContains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); } public verifyNoRollenValidationError(): void { - notContains(this.benutzerPage.getRollenValidationError(), BenutzerE2EComponent.ROLLEN_EMPTY_ERROR); + notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } } diff --git a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts new file mode 100644 index 0000000000..c99605b360 --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts @@ -0,0 +1,9 @@ +export enum BenutzerValidationMessages { + VORNAME_EMPTY_ERROR = 'Bitte Vorname ausfüllen', + NACHNAME_EMPTY_ERROR = 'Bitte Nachname ausfüllen', + BENUTZERNAME_SIZE_ERROR = 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten', + EMAIL_INVALID_ERROR = 'Bitte E-Mail korrekt ausfüllen', + ROLLEN_EMPTY_ERROR = 'Bitte Rollen ausfüllen', + BENUTZER_NAME_EXISTS = 'Benutzername bereits verwendet', + EMAIL_EXISTS = 'Email-Adresse bereits verwendet', +} -- GitLab From 261df9e59358d5eb7a2aa1ecb96a2840efcfc525 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 19:25:54 +0100 Subject: [PATCH 11/22] OZG-7591 fix e2e --- .../src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts index d5b90ab701..3d3ad67524 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts @@ -134,7 +134,7 @@ describe('Benutzer anlegen', () => { it('should show validation error on existing email', () => { benutzerHelper.openNewBenutzerPage(); - benutzerHelper.editBenutzer(newUser); + benutzerHelper.addBenutzer(newUser); benutzerHelper.saveBenutzer(); benutzerVerifier.verifyEmailExistsError(); @@ -142,7 +142,7 @@ describe('Benutzer anlegen', () => { it('should show validation error on existing username', () => { benutzerHelper.openNewBenutzerPage(); - benutzerHelper.editBenutzer({ ...newUser, email: faker.internet.email() + '.local' }); + benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' }); benutzerHelper.saveBenutzer(); benutzerVerifier.verifyUserExistsError(); -- GitLab From deb94b59987a8c5c3152f2b48d0e81381200883c Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 20:35:15 +0100 Subject: [PATCH 12/22] OZG-7591 add text editor e2e component CR --- .../benutzer/benutzer.e2e.component.ts | 37 +++++++++---------- .../ods/text-editor.e2e.component.ts | 17 +++++++++ .../src/helper/benutzer/benutzer.helper.ts | 3 ++ .../src/helper/benutzer/benutzer.verifier.ts | 31 ++++++++-------- 4 files changed, 53 insertions(+), 35 deletions(-) create mode 100644 alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index fc463536e8..86924dcfd1 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -23,6 +23,7 @@ */ import 'cypress-real-events'; import { convertToDataTestId } from '../../support/tech-util'; +import { TextEditorE2eComponent } from '../ods/text-editor.e2e.component'; //TODO BenutzerListPage erstellen welche den Button und die Liste enthaelt. export class BenutzerListE2EComponent { @@ -95,11 +96,6 @@ export class BenutzerListItemE2EComponent { export class BenutzerE2EComponent { private readonly headline: string = 'benutzer-form-headline'; - private readonly userVorname: string = 'firstName-text-input'; - private readonly userNachname: string = 'lastName-text-input'; - private readonly userBenutzername: string = 'username-text-input'; - private readonly userMail: string = 'email-text-input'; - private readonly adminCheckboxLabel: string = 'Admin'; private readonly loeschenCheckboxLabel: string = 'Löschen'; private readonly userCheckboxLabel: string = 'User'; @@ -111,12 +107,13 @@ export class BenutzerE2EComponent { private readonly saveButton: string = 'save-button'; private readonly deleteButton: string = 'delete-button'; - private readonly firstNameValidationError: string = 'firstName-text-editor-error'; - private readonly lastNameValidationError: string = 'lastName-text-editor-error'; - private readonly usernameValidationError: string = 'username-text-editor-error'; - private readonly emailValidationError: string = 'email-text-editor-error'; private readonly rollenValidationError: string = 'rollen-error'; + private readonly vornameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('firstName'); + private readonly nachnameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('lastName'); + private readonly benutzernameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('username'); + private readonly emailTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('email'); + public getHeadline(): Cypress.Chainable<Element> { return cy.getTestElement(this.headline); } @@ -126,19 +123,19 @@ export class BenutzerE2EComponent { } public getVornameInput(): Cypress.Chainable<Element> { - return cy.getTestElement(this.userVorname); + return this.vornameTextFeld.getInput(); } public getNachnameInput(): Cypress.Chainable<Element> { - return cy.getTestElement(this.userNachname); + return this.nachnameTextFeld.getInput(); } public getBenutzernameInput(): Cypress.Chainable<Element> { - return cy.getTestElement(this.userBenutzername); + return this.benutzernameTextFeld.getInput(); } public getMailInput(): Cypress.Chainable<Element> { - return cy.getTestElement(this.userMail); + return this.emailTextFeld.getInput(); } public getAdminCheckbox(): BenutzerCheckboxE2EComponent { @@ -173,20 +170,20 @@ export class BenutzerE2EComponent { return cy.getTestElement(this.deleteButton); } - public getFirstNameValidationError(): Cypress.Chainable<Element> { - return cy.getTestElement(this.firstNameValidationError); + public getVornameValidationError(): Cypress.Chainable<Element> { + return this.vornameTextFeld.getErrorMessage(); } - public getLastNameValidationError(): Cypress.Chainable<Element> { - return cy.getTestElement(this.lastNameValidationError); + public getNachnameValidationError(): Cypress.Chainable<Element> { + return this.nachnameTextFeld.getErrorMessage(); } - public getUsernameValidationError(): Cypress.Chainable<Element> { - return cy.getTestElement(this.usernameValidationError); + public getBenutzernameValidationError(): Cypress.Chainable<Element> { + return this.benutzernameTextFeld.getErrorMessage(); } public getEmailValidationError(): Cypress.Chainable<Element> { - return cy.getTestElement(this.emailValidationError); + return this.emailTextFeld.getErrorMessage(); } public getRollenValidationError(): Cypress.Chainable<Element> { diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts new file mode 100644 index 0000000000..d3fede9efe --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts @@ -0,0 +1,17 @@ +export class TextEditorE2eComponent { + private readonly root: string; + private readonly validationError: string; + + constructor(root: string) { + this.root = `${root}-text-editor`; + this.validationError = `${root}-text-editor-error`; + } + + public getInput(): Cypress.Chainable<Element> { + return cy.getTestElement(this.root); + } + + public getErrorMessage(): Cypress.Chainable<Element> { + return cy.getTestElement(this.validationError); + } +} diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts index 1a8deaf8db..72ef2c819e 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.helper.ts @@ -1,5 +1,6 @@ import { OrganisationsEinheitE2E } from '../../model/organisations-einheit'; import { AdminUserE2E } from '../../model/util'; +import { waitForSpinnerToDisappear } from '../../page-objects/main.po'; import { E2EBenutzerExecutor } from './benutzer.executor'; import { E2EBenutzerNavigator } from './benutzer.navigator'; @@ -72,11 +73,13 @@ export class E2EBenutzerHelper { public saveBenutzer(): void { this.executer.saveBenutzer(); + waitForSpinnerToDisappear(); } public deleteBenutzer(userName: string): void { this.openBenutzerPage(userName); this.executer.deleteBenutzer(); + waitForSpinnerToDisappear(); } public openBenutzerPage(userName: string): void { diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index afd3f0539a..0b12ee8679 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -1,3 +1,4 @@ +import { EMPTY_STRING } from '@alfa-client/tech-shared'; import { BenutzerE2EComponent, BenutzerListE2EComponent, @@ -5,7 +6,7 @@ import { } from '../../components/benutzer/benutzer.e2e.component'; import { BenutzerValidationMessages } from '../../model/benutzer.messages'; import { AdminUserE2E } from '../../model/util'; -import { contains, exist, notBeEnabled, notContains, notExist } from '../../support/cypress.util'; +import { contains, exist, haveText, notBeEnabled, notContains, notExist } from '../../support/cypress.util'; import { AlfaRollen } from '../../support/user-util'; export class E2EBenutzerVerifier { @@ -70,33 +71,33 @@ export class E2EBenutzerVerifier { } public verifyMissingVornameError(): void { - exist(this.benutzerPage.getFirstNameValidationError()); - contains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); + exist(this.benutzerPage.getVornameValidationError()); + haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); } public verifyLastNameError(): void { - exist(this.benutzerPage.getLastNameValidationError()); - contains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); + exist(this.benutzerPage.getNachnameValidationError()); + haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } public verifyUsernameSizeError(): void { - exist(this.benutzerPage.getUsernameValidationError()); - contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); + exist(this.benutzerPage.getBenutzernameValidationError()); + haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); } public verifyUserExistsError(): void { - exist(this.benutzerPage.getUsernameValidationError()); - contains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); + exist(this.benutzerPage.getBenutzernameValidationError()); + haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); } public verifyEmailInvalidError(): void { exist(this.benutzerPage.getEmailValidationError()); - contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); + haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); } public verifyEmailExistsError(): void { exist(this.benutzerPage.getEmailValidationError()); - contains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS); + haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS); } public verifyRollenError(): void { @@ -105,19 +106,19 @@ export class E2EBenutzerVerifier { } public verifyNoFirstNameValidationError(): void { - notContains(this.benutzerPage.getFirstNameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); + haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING); } public verifyNoLastNameValidationError(): void { - notContains(this.benutzerPage.getLastNameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); + haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING); } public verifyNoUsernameSizeValidationError(): void { - notContains(this.benutzerPage.getUsernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); + haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING); } public verifyNoEmailInvalidValidationError(): void { - notContains(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); + haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING); } public verifyNoRollenValidationError(): void { -- GitLab From 24028756df756275660d1f1b74adc3db6a1e7cf0 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 20:50:08 +0100 Subject: [PATCH 13/22] OZG-7591 fix e2e component --- .../admin-e2e/src/components/ods/text-editor.e2e.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts index d3fede9efe..c7ba7ea9c1 100644 --- a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts @@ -1,14 +1,16 @@ export class TextEditorE2eComponent { private readonly root: string; + private readonly input: string; private readonly validationError: string; constructor(root: string) { this.root = `${root}-text-editor`; + this.input = `${root}-text-input`; this.validationError = `${root}-text-editor-error`; } public getInput(): Cypress.Chainable<Element> { - return cy.getTestElement(this.root); + return cy.getTestElement(this.input); } public getErrorMessage(): Cypress.Chainable<Element> { -- GitLab From 95f0f0c639dfcce872ec1a6953107934c59e6b5e Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Thu, 13 Mar 2025 21:11:35 +0100 Subject: [PATCH 14/22] OZG-7591 fix e2e component --- .../benutzer_rollen/benutzer-anlegen.cy.ts | 80 +++++++++---------- .../src/helper/benutzer/benutzer.verifier.ts | 22 ++--- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts index 3d3ad67524..43af53698b 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts @@ -64,66 +64,66 @@ describe('Benutzer anlegen', () => { benutzerVerifier.verifyUserNotInList(newUser.username); }); - describe('client validation errors', () => { - it('should show all if empty form', () => { + describe('when filling formular', () => { + it('should show validation errors on empty formular', () => { benutzerHelper.openNewBenutzerPage(); benutzerHelper.saveBenutzer(); - benutzerVerifier.verifyMissingVornameError(); - benutzerVerifier.verifyLastNameError(); - benutzerVerifier.verifyUsernameSizeError(); - benutzerVerifier.verifyEmailInvalidError(); - benutzerVerifier.verifyRollenError(); + benutzerVerifier.verifyEmptyVornameError(); + benutzerVerifier.verifyEmptyNameError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); }); - it('should hide Vorname validation error', () => { + it('should hide Vorname validation error on filled', () => { benutzerHelper.editVorname('Max'); - benutzerVerifier.verifyNoFirstNameValidationError(); - benutzerVerifier.verifyLastNameError(); - benutzerVerifier.verifyUsernameSizeError(); - benutzerVerifier.verifyEmailInvalidError(); - benutzerVerifier.verifyRollenError(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyEmptyNameError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); }); - it('should hide Nachname validation error', () => { + it('should hide Nachname validation error on filled', () => { benutzerHelper.editNachname('Mustermann'); - benutzerVerifier.verifyNoFirstNameValidationError(); - benutzerVerifier.verifyNoLastNameValidationError(); - benutzerVerifier.verifyUsernameSizeError(); - benutzerVerifier.verifyEmailInvalidError(); - benutzerVerifier.verifyRollenError(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); }); - it('should hide Benutzername validation error', () => { + it('should hide Benutzername validation error on filled', () => { benutzerHelper.editBenutzername('Max'); - benutzerVerifier.verifyNoFirstNameValidationError(); - benutzerVerifier.verifyNoLastNameValidationError(); - benutzerVerifier.verifyNoUsernameSizeValidationError(); - benutzerVerifier.verifyEmailInvalidError(); - benutzerVerifier.verifyRollenError(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); }); - it('should hide Email validation error', () => { + it('should hide Email validation error on filled', () => { benutzerHelper.editEmail('max@max.local'); - benutzerVerifier.verifyNoFirstNameValidationError(); - benutzerVerifier.verifyNoLastNameValidationError(); - benutzerVerifier.verifyNoUsernameSizeValidationError(); - benutzerVerifier.verifyNoEmailInvalidValidationError(); - benutzerVerifier.verifyRollenError(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyEmailHasNoError(); + benutzerVerifier.verifyEmptyRollenError(); }); - it('should hide Rollen validation error', () => { + it('should hide Rollen validation error on filled', () => { benutzerHelper.addUserRole(); - benutzerVerifier.verifyNoFirstNameValidationError(); - benutzerVerifier.verifyNoLastNameValidationError(); - benutzerVerifier.verifyNoUsernameSizeValidationError(); - benutzerVerifier.verifyNoEmailInvalidValidationError(); - benutzerVerifier.verifyNoRollenValidationError(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyEmailHasNoError(); + benutzerVerifier.verifyRollenHasNoError(); }); }); - describe('server validation errors', () => { - it('should create user', () => { + describe('after formular submission', () => { + it('should have user created', () => { benutzerHelper.openNewBenutzerPage(); benutzerHelper.addBenutzer(newUser); @@ -145,7 +145,7 @@ describe('Benutzer anlegen', () => { benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' }); benutzerHelper.saveBenutzer(); - benutzerVerifier.verifyUserExistsError(); + benutzerVerifier.verifyUsernameExistsError(); }); it('should remove benutzer', () => { diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index 0b12ee8679..5618af6a9f 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -70,27 +70,27 @@ export class E2EBenutzerVerifier { return this.benutzerListPage.getItem(userName); } - public verifyMissingVornameError(): void { + public verifyEmptyVornameError(): void { exist(this.benutzerPage.getVornameValidationError()); haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); } - public verifyLastNameError(): void { + public verifyEmptyNameError(): void { exist(this.benutzerPage.getNachnameValidationError()); haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } - public verifyUsernameSizeError(): void { + public verifyUsernameLengthError(): void { exist(this.benutzerPage.getBenutzernameValidationError()); haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); } - public verifyUserExistsError(): void { + public verifyUsernameExistsError(): void { exist(this.benutzerPage.getBenutzernameValidationError()); haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); } - public verifyEmailInvalidError(): void { + public verifyInvalidEmailError(): void { exist(this.benutzerPage.getEmailValidationError()); haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); } @@ -100,28 +100,28 @@ export class E2EBenutzerVerifier { haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS); } - public verifyRollenError(): void { + public verifyEmptyRollenError(): void { exist(this.benutzerPage.getRollenValidationError()); contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } - public verifyNoFirstNameValidationError(): void { + public verifyVornameHasNoError(): void { haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING); } - public verifyNoLastNameValidationError(): void { + public verifyLastNameHasNoError(): void { haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING); } - public verifyNoUsernameSizeValidationError(): void { + public verifyUsernameHasNoError(): void { haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING); } - public verifyNoEmailInvalidValidationError(): void { + public verifyEmailHasNoError(): void { haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING); } - public verifyNoRollenValidationError(): void { + public verifyRollenHasNoError(): void { notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } } -- GitLab From 0be832bb30b4ee3dda43e07daa8a9be2eff7c640 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Mon, 17 Mar 2025 17:14:38 +0100 Subject: [PATCH 15/22] OZG-7591 refactor e2e component --- .../benutzer/benutzer.e2e.component.ts | 16 +++++----- .../src/helper/benutzer/benutzer.verifier.ts | 32 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index 86924dcfd1..d875e2832c 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -170,20 +170,20 @@ export class BenutzerE2EComponent { return cy.getTestElement(this.deleteButton); } - public getVornameValidationError(): Cypress.Chainable<Element> { - return this.vornameTextFeld.getErrorMessage(); + public getVornameTextFeld(): TextEditorE2eComponent { + return this.vornameTextFeld; } - public getNachnameValidationError(): Cypress.Chainable<Element> { - return this.nachnameTextFeld.getErrorMessage(); + public getNachnameTextFeld(): TextEditorE2eComponent { + return this.nachnameTextFeld; } - public getBenutzernameValidationError(): Cypress.Chainable<Element> { - return this.benutzernameTextFeld.getErrorMessage(); + public getBenutzernameTextFeld(): TextEditorE2eComponent { + return this.benutzernameTextFeld; } - public getEmailValidationError(): Cypress.Chainable<Element> { - return this.emailTextFeld.getErrorMessage(); + public getEmailTextFeld(): TextEditorE2eComponent { + return this.emailTextFeld; } public getRollenValidationError(): Cypress.Chainable<Element> { diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index 5618af6a9f..120f766515 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -71,33 +71,33 @@ export class E2EBenutzerVerifier { } public verifyEmptyVornameError(): void { - exist(this.benutzerPage.getVornameValidationError()); - haveText(this.benutzerPage.getVornameValidationError(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); + exist(this.benutzerPage.getVornameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); } public verifyEmptyNameError(): void { - exist(this.benutzerPage.getNachnameValidationError()); - haveText(this.benutzerPage.getNachnameValidationError(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); + exist(this.benutzerPage.getNachnameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } public verifyUsernameLengthError(): void { - exist(this.benutzerPage.getBenutzernameValidationError()); - haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); + exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); } public verifyUsernameExistsError(): void { - exist(this.benutzerPage.getBenutzernameValidationError()); - haveText(this.benutzerPage.getBenutzernameValidationError(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); + exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); } public verifyInvalidEmailError(): void { - exist(this.benutzerPage.getEmailValidationError()); - haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); + exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); } public verifyEmailExistsError(): void { - exist(this.benutzerPage.getEmailValidationError()); - haveText(this.benutzerPage.getEmailValidationError(), BenutzerValidationMessages.EMAIL_EXISTS); + exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_EXISTS); } public verifyEmptyRollenError(): void { @@ -106,19 +106,19 @@ export class E2EBenutzerVerifier { } public verifyVornameHasNoError(): void { - haveText(this.benutzerPage.getVornameValidationError(), EMPTY_STRING); + haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), EMPTY_STRING); } public verifyLastNameHasNoError(): void { - haveText(this.benutzerPage.getNachnameValidationError(), EMPTY_STRING); + haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), EMPTY_STRING); } public verifyUsernameHasNoError(): void { - haveText(this.benutzerPage.getBenutzernameValidationError(), EMPTY_STRING); + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), EMPTY_STRING); } public verifyEmailHasNoError(): void { - haveText(this.benutzerPage.getEmailValidationError(), EMPTY_STRING); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), EMPTY_STRING); } public verifyRollenHasNoError(): void { -- GitLab From 55e95208bf55c5b59e6697f46ffaccdc6f456f44 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Mon, 17 Mar 2025 17:14:56 +0100 Subject: [PATCH 16/22] OZG-7591 rename e2e component --- .../benutzer/benutzer.e2e.component.ts | 18 +++++++++--------- ...ponent.ts => text-editor-e2-e.component.ts} | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) rename alfa-client/apps/admin-e2e/src/components/ods/{text-editor.e2e.component.ts => text-editor-e2-e.component.ts} (92%) diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index d875e2832c..c80f8d4b50 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -23,7 +23,7 @@ */ import 'cypress-real-events'; import { convertToDataTestId } from '../../support/tech-util'; -import { TextEditorE2eComponent } from '../ods/text-editor.e2e.component'; +import { TextEditorE2EComponent } from '../ods/text-editor-e2-e.component'; //TODO BenutzerListPage erstellen welche den Button und die Liste enthaelt. export class BenutzerListE2EComponent { @@ -109,10 +109,10 @@ export class BenutzerE2EComponent { private readonly rollenValidationError: string = 'rollen-error'; - private readonly vornameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('firstName'); - private readonly nachnameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('lastName'); - private readonly benutzernameTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('username'); - private readonly emailTextFeld: TextEditorE2eComponent = new TextEditorE2eComponent('email'); + private readonly vornameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('firstName'); + private readonly nachnameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('lastName'); + private readonly benutzernameTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('username'); + private readonly emailTextFeld: TextEditorE2EComponent = new TextEditorE2EComponent('email'); public getHeadline(): Cypress.Chainable<Element> { return cy.getTestElement(this.headline); @@ -170,19 +170,19 @@ export class BenutzerE2EComponent { return cy.getTestElement(this.deleteButton); } - public getVornameTextFeld(): TextEditorE2eComponent { + public getVornameTextFeld(): TextEditorE2EComponent { return this.vornameTextFeld; } - public getNachnameTextFeld(): TextEditorE2eComponent { + public getNachnameTextFeld(): TextEditorE2EComponent { return this.nachnameTextFeld; } - public getBenutzernameTextFeld(): TextEditorE2eComponent { + public getBenutzernameTextFeld(): TextEditorE2EComponent { return this.benutzernameTextFeld; } - public getEmailTextFeld(): TextEditorE2eComponent { + public getEmailTextFeld(): TextEditorE2EComponent { return this.emailTextFeld; } diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts similarity index 92% rename from alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts rename to alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts index c7ba7ea9c1..0605779543 100644 --- a/alfa-client/apps/admin-e2e/src/components/ods/text-editor.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts @@ -1,4 +1,4 @@ -export class TextEditorE2eComponent { +export class TextEditorE2EComponent { private readonly root: string; private readonly input: string; private readonly validationError: string; -- GitLab From bb27c1a197aca3f848b7e40bb5159ff3a3e4a1b4 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Mon, 17 Mar 2025 17:16:04 +0100 Subject: [PATCH 17/22] OZG-7591 rename e2e enum --- .../src/helper/benutzer/benutzer.verifier.ts | 21 +++++++++++-------- .../admin-e2e/src/model/benutzer.messages.ts | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts index 120f766515..22f42fb09f 100644 --- a/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts +++ b/alfa-client/apps/admin-e2e/src/helper/benutzer/benutzer.verifier.ts @@ -4,7 +4,7 @@ import { BenutzerListE2EComponent, BenutzerListItemE2EComponent, } from '../../components/benutzer/benutzer.e2e.component'; -import { BenutzerValidationMessages } from '../../model/benutzer.messages'; +import { E2EBenutzerValidationMessages } from '../../model/benutzer.messages'; import { AdminUserE2E } from '../../model/util'; import { contains, exist, haveText, notBeEnabled, notContains, notExist } from '../../support/cypress.util'; import { AlfaRollen } from '../../support/user-util'; @@ -72,37 +72,40 @@ export class E2EBenutzerVerifier { public verifyEmptyVornameError(): void { exist(this.benutzerPage.getVornameTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), BenutzerValidationMessages.VORNAME_EMPTY_ERROR); + haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.VORNAME_EMPTY_ERROR); } public verifyEmptyNameError(): void { exist(this.benutzerPage.getNachnameTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), BenutzerValidationMessages.NACHNAME_EMPTY_ERROR); + haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.NACHNAME_EMPTY_ERROR); } public verifyUsernameLengthError(): void { exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR); + haveText( + this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), + E2EBenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR, + ); } public verifyUsernameExistsError(): void { exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), BenutzerValidationMessages.BENUTZER_NAME_EXISTS); + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.BENUTZER_NAME_EXISTS); } public verifyInvalidEmailError(): void { exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_INVALID_ERROR); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_INVALID_ERROR); } public verifyEmailExistsError(): void { exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); - haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), BenutzerValidationMessages.EMAIL_EXISTS); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_EXISTS); } public verifyEmptyRollenError(): void { exist(this.benutzerPage.getRollenValidationError()); - contains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); + contains(this.benutzerPage.getRollenValidationError(), E2EBenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } public verifyVornameHasNoError(): void { @@ -122,6 +125,6 @@ export class E2EBenutzerVerifier { } public verifyRollenHasNoError(): void { - notContains(this.benutzerPage.getRollenValidationError(), BenutzerValidationMessages.ROLLEN_EMPTY_ERROR); + notContains(this.benutzerPage.getRollenValidationError(), E2EBenutzerValidationMessages.ROLLEN_EMPTY_ERROR); } } diff --git a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts index c99605b360..db6aa9f09f 100644 --- a/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts +++ b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts @@ -1,4 +1,4 @@ -export enum BenutzerValidationMessages { +export enum E2EBenutzerValidationMessages { VORNAME_EMPTY_ERROR = 'Bitte Vorname ausfüllen', NACHNAME_EMPTY_ERROR = 'Bitte Nachname ausfüllen', BENUTZERNAME_SIZE_ERROR = 'Benutzername muss mindestens 3 und darf höchstens 255 Zeichen enthalten', -- GitLab From 10a784c7b63fd198520ea306f52f76ea653dc03b Mon Sep 17 00:00:00 2001 From: Martin <git@mail.de> Date: Tue, 18 Mar 2025 09:49:49 +0100 Subject: [PATCH 18/22] OZG-7591 add validation error id for aria-describedby --- .../user-form-roles.component.html | 1 + .../user-form-roles.component.ts | 3 +- .../checkbox-editor.component.html | 2 ++ .../checkbox-editor.component.ts | 4 ++- .../text-editor/text-editor.component.html | 2 ++ .../form/text-editor/text-editor.component.ts | 4 ++- .../textarea-editor.component.html | 2 ++ .../textarea-editor.component.ts | 4 ++- .../validation-error.component.html | 2 +- .../validation-error.component.ts | 1 + .../lib/form/checkbox/checkbox.component.ts | 4 ++- .../error-message/error-message.component.ts | 4 ++- .../form/text-input/text-input.component.ts | 4 ++- .../lib/form/textarea/textarea.component.ts | 4 ++- .../lib/validation/tech.validation.util.ts | 36 ++++++------------- 15 files changed, 43 insertions(+), 34 deletions(-) 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 38380b3fa1..f410d02855 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 @@ -1,5 +1,6 @@ <h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2> <ods-validation-error + [id]="validationErrorId" [invalidParams]="invalidParams$ | async" label="Rollen" data-test-id="rollen-error" 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 70a8bdfdcc..37336816f4 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,4 +1,4 @@ -import { InvalidParam } from '@alfa-client/tech-shared'; +import { generateValidationErrorId, InvalidParam } from '@alfa-client/tech-shared'; import { AsyncPipe } from '@angular/common'; import { Component, Input, OnInit } from '@angular/core'; import { AbstractControl, FormControlStatus, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; @@ -26,6 +26,7 @@ export class UserFormRolesComponent implements OnInit { public invalidParams$: Observable<InvalidParam[]> = of([]); public readonly UserFormService = UserFormService; + public readonly validationErrorId: string = generateValidationErrorId(); ngOnInit(): void { const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES]; 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 2533dc1ed4..1cc8b07470 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 @@ -29,9 +29,11 @@ [label]="label" [disabled]="control.disabled" [hasError]="hasError" + [ariaDescribedBy]="validationErrorId" > <ods-validation-error error + [id]="validationErrorId" [invalidParams]="invalidParams" [label]="label" [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor-error'" 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 dfab859671..bbb9f693fc 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 @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; +import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared'; import { Component, Input } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { CheckboxComponent } from '@ods/system'; @@ -38,6 +38,8 @@ export class CheckboxEditorComponent extends FormControlEditorAbstractComponent @Input() inputId: string; @Input() label: string; + public readonly validationErrorId: string = generateValidationErrorId(); + get hasError(): boolean { return this.invalidParams.length > 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 c4c0091074..3c116fb0b2 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 @@ -34,9 +34,11 @@ [required]="isRequired" [focus]="focus" [showLabel]="showLabel" + [ariaDescribedBy]="validationErrorId" > <ods-validation-error error + [id]="validationErrorId" [invalidParams]="invalidParams" [label]="label" [attr.data-test-id]="dataTestId + '-text-editor-error'" 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 45f7c42c8f..19ae4d4d43 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 @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; +import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; @@ -44,6 +44,8 @@ export class TextEditorComponent extends FormControlEditorAbstractComponent { @Input() showLabel: boolean = true; @Input() dataTestId: string; + public readonly validationErrorId: string = generateValidationErrorId(); + get variant(): string { return this.invalidParams.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 39ac310964..753a2c385a 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 @@ -34,9 +34,11 @@ [focus]="focus" [isResizable]="isResizable" [showLabel]="showLabel" + [ariaDescribedBy]="validationErrorId" > <ods-validation-error error + [id]="validationErrorId" [invalidParams]="invalidParams" [label]="label" [attr.data-test-id]="(label | convertForDataTest) + '-textarea-editor-error'" 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 c7032ef03b..9203da053f 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 @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; +import { ConvertForDataTestPipe, generateValidationErrorId } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; @@ -44,6 +44,8 @@ export class TextareaEditorComponent extends FormControlEditorAbstractComponent @Input() isResizable: boolean = true; @Input() showLabel: boolean = true; + public readonly validationErrorId: string = generateValidationErrorId(); + get variant(): string { return this.invalidParams.length > 0 ? 'error' : 'default'; } diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html index 224dbe8cc5..cb566bafe0 100644 --- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html +++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.html @@ -24,5 +24,5 @@ --> <ng-container *ngFor="let invalidParam of invalidParams" - ><ods-error-message [text]="message(invalidParam)"></ods-error-message + ><ods-error-message [id]="id" [text]="message(invalidParam)"></ods-error-message ></ng-container> diff --git a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts index 8844b8075d..fca8d2a2ec 100644 --- a/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/validation-error/validation-error.component.ts @@ -33,6 +33,7 @@ import { ErrorMessageComponent } from '@ods/system'; templateUrl: './validation-error.component.html', }) export class ValidationErrorComponent { + @Input({ required: true }) id: string; @Input() label: string; @Input() invalidParams: InvalidParam[]; 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 ffadf796c9..3b3750bd31 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 @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; +import { ConvertForDataTestPipe, EMPTY_STRING } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; @@ -45,6 +45,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; [attr.id]="inputId" [attr.disabled]="disabled ? true : null" [attr.data-test-id]="(label | convertForDataTest) + '-checkbox-editor'" + [attr.aria-describedby]="ariaDescribedBy" /> <label class="leading-5 text-text" [attr.for]="inputId">{{ label }}</label> <svg @@ -69,4 +70,5 @@ export class CheckboxComponent { @Input() label: string; @Input() disabled: boolean = false; @Input() hasError: boolean = false; + @Input() ariaDescribedBy: string = EMPTY_STRING; } diff --git a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts index 66ed08e7f9..8c33dd8452 100644 --- a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts @@ -21,6 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { EMPTY_STRING } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamation-icon.component'; @@ -31,13 +32,14 @@ import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamati imports: [CommonModule, ExclamationIconComponent], styles: [':host {@apply flex text-error my-2 text-sm items-center font-medium}'], template: `<ods-exclamation-icon class="mr-1"></ods-exclamation-icon> - <div class="flex-grow break-all"> + <div class="flex-grow break-all" [id]="id"> {{ text }} <br *ngIf="subText" aria-hidden="true" /> {{ subText }} </div> `, }) export class ErrorMessageComponent { + @Input() id: string = EMPTY_STRING; @Input({ required: true }) text!: string; @Input() subText: string = ''; } 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 cd01ba0190..4a8ddff099 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 @@ -62,6 +62,7 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; <ng-content select="[prefix]" /> </div> <input + #inputElement type="text" [id]="id" [formControl]="fieldControl" @@ -71,8 +72,8 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; [attr.aria-required]="required" [attr.aria-invalid]="variant === 'error'" [attr.data-test-id]="_dataTestId + '-text-input'" + [attr.aria-describedby]="ariaDescribedBy" (click)="clickEmitter.emit()" - #inputElement /> <div *ngIf="withSuffix" class="absolute bottom-2 right-2 flex size-6 items-center justify-center"> <ng-content select="[suffix]" /> @@ -102,6 +103,7 @@ export class TextInputComponent implements AfterViewInit { @Input() set dataTestId(value: string) { if (isNotUndefined(value)) this._dataTestId = value; } + @Input() ariaDescribedBy: string = EMPTY_STRING; @Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); 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 f3eb84af9d..d3c04b6c82 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 @@ -57,6 +57,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>; {{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> </label> <textarea + #textAreaElement [id]="id" [formControl]="fieldControl" [rows]="rows" @@ -66,7 +67,7 @@ type TextareaVariants = VariantProps<typeof textareaVariants>; [attr.aria-required]="required" [attr.aria-invalid]="variant === 'error'" [attr.data-test-id]="(inputLabel | convertForDataTest) + '-textarea'" - #textAreaElement + [attr.aria-describedby]="ariaDescribedBy" ></textarea> <ng-content select="[error]"></ng-content> </div> @@ -94,6 +95,7 @@ export class TextareaComponent { this.textAreaElement.nativeElement.focus(); } } + @Input() ariaDescribedBy: string = EMPTY_STRING; inputLabel: string; id: string; diff --git a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts index b0cb78f8a1..7f8f416bf9 100644 --- a/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts +++ b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.util.ts @@ -22,7 +22,7 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { AbstractControl, UntypedFormGroup } from '@angular/forms'; -import { isEmpty, isNil } from 'lodash-es'; +import { isEmpty, isNil, uniqueId } from 'lodash-es'; import { ApiError, InvalidParam, Issue, IssueParam, ProblemDetail } from '../tech.model'; import { replacePlaceholder } from '../tech.util'; import { VALIDATION_MESSAGES, ValidationMessageCode } from './tech.validation.messages'; @@ -31,28 +31,18 @@ export function isValidationError(issue: Issue): boolean { return issue.messageCode.includes('javax.validation.constraints'); } -export function setIssueValidationError( - form: UntypedFormGroup, - issue: Issue, - pathPrefix?: string, -): void { +export function setIssueValidationError(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): void { const control: AbstractControl = getControlForIssue(form, issue, pathPrefix); control.setErrors({ [issue.messageCode]: issue }); control.markAsTouched(); } -export function getControlForIssue( - form: UntypedFormGroup, - issue: Issue, - pathPrefix?: string, -): AbstractControl { +export function getControlForIssue(form: UntypedFormGroup, issue: Issue, pathPrefix?: string): AbstractControl { const fieldPath: string = getFieldPath(issue.field, pathPrefix); let curControl: AbstractControl = form; - fieldPath - .split('.') - .forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field])); + fieldPath.split('.').forEach((field) => (curControl = (<UntypedFormGroup>curControl).controls[field])); return curControl; } @@ -66,9 +56,7 @@ export function getMessageForIssue(label: string, issue: Issue): string { } msg = replacePlaceholder(msg, 'field', label); - issue.parameters.forEach( - (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)), - ); + issue.parameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value))); return msg; } @@ -84,11 +72,7 @@ export function getMessageCode(apiError: ApiError): string { return apiError.issues[0].messageCode; } -export function setInvalidParamValidationError( - form: UntypedFormGroup, - invalidParam: InvalidParam, - pathPrefix?: string, -): void { +export function setInvalidParamValidationError(form: UntypedFormGroup, invalidParam: InvalidParam, pathPrefix?: string): void { const control: AbstractControl = getControlForInvalidParam(form, invalidParam, pathPrefix); control.setErrors({ [invalidParam.reason]: invalidParam }); @@ -112,9 +96,7 @@ export function getMessageForInvalidParam(label: string, invalidParam: InvalidPa } msg = replacePlaceholder(msg, 'field', label); - invalidParam.constraintParameters.forEach( - (param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value)), - ); + invalidParam.constraintParameters.forEach((param: IssueParam) => (msg = replacePlaceholder(msg, param.name, param.value))); return msg; } @@ -126,3 +108,7 @@ export function getFieldPath(name: string, pathPrefix: string): string { const indexOfField = name.lastIndexOf(pathPrefix) + pathPrefix.length + 1; return name.slice(indexOfField); } + +export function generateValidationErrorId(): string { + return `${uniqueId()}-validation-error`; +} -- GitLab From e45895e9dc41360a0d4fc6a6be5ab2172be36eb6 Mon Sep 17 00:00:00 2001 From: Oliver Schmidt <kontakt@webkreation.de> Date: Tue, 18 Mar 2025 18:39:29 +0100 Subject: [PATCH 19/22] OZG-7591-7932 opt form ui and a11y --- ...-organisations-einheit-list.component.html | 21 +++++++++------- .../user-form-roles.component.html | 24 ++++++++++++------- .../user-form-roles.component.ts | 5 +++- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html index 5d3fea2f35..2c1d3520d1 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-organisations-einheit-list/user-form-organisations-einheit-list.component.html @@ -1,11 +1,14 @@ -<div class="mb-12 block"> - <h2 class="heading-2 mt-4">Organisationseinheiten</h2> - <div [formGroup]="formGroupParent"> - <div [formGroupName]="UserFormService.GROUPS" class="flex flex-col gap-2"> - <p class="font-medium">Dem Benutzer sind folgende Organisationseinheiten zugewiesen</p> - @for (controlName of formGroupOrganisationsEinheiten.controls | keyvalue; track controlName.key) { - <ods-checkbox-editor [formControlName]="controlName.key" [label]="controlName.key" [inputId]="controlName.key" /> - } - </div> +<div + [formGroup]="formGroupParent" + role="group" + aria-labelledby="organisationseinheiten-heading organisationseinheiten-desc" + class="mb-12" +> + <h2 id="organisationseinheiten-heading" class="heading-2 mt-4">Organisationseinheiten</h2> + <p id="organisationseinheiten-desc" class="mb-2 font-medium">Dem Benutzer sind folgende Organisationseinheiten zugewiesen</p> + <div [formGroupName]="UserFormService.GROUPS" class="flex flex-col gap-2"> + @for (controlName of formGroupOrganisationsEinheiten.controls | keyvalue; track controlName.key) { + <ods-checkbox-editor [formControlName]="controlName.key" [label]="controlName.key" [inputId]="controlName.key" /> + } </div> </div> 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 f410d02855..d3d39a71ce 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 @@ -1,12 +1,12 @@ -<h2 class="heading-2 mt-4">Rollen für OZG-Cloud *</h2> -<ods-validation-error - [id]="validationErrorId" - [invalidParams]="invalidParams$ | async" - label="Rollen" - data-test-id="rollen-error" -></ods-validation-error> -<div [formGroup]="formGroupParent"> - <div [formGroupName]="UserFormService.CLIENT_ROLES" class="mb-8 flex flex-col gap-4 md:flex-row"> +<h2 class="heading-2 mt-4" id="rollen-ozg-heading">Rollen für OZG-Cloud<i aria-hidden="true">*</i></h2> +<div + [formGroup]="formGroupParent" + role="group" + aria-required="true" + aria-labelledby="rollen-ozg-heading" + [attr.aria-invalid]="!isValid" +> + <div [formGroupName]="UserFormService.CLIENT_ROLES" class="flex flex-col gap-4 md:flex-row"> <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"> @@ -60,4 +60,10 @@ </div> </div> </div> + <ods-validation-error + [id]="validationErrorId" + [invalidParams]="invalidParams$ | async" + label="Rollen" + data-test-id="rollen-error" + ></ods-validation-error> </div> 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 37336816f4..7a8c901dab 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 @@ -4,7 +4,8 @@ import { Component, 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'; -import { map, Observable, of } from 'rxjs'; +import { isEmpty } from 'lodash-es'; +import { map, Observable, of, tap } from 'rxjs'; import { UserFormService } from '../user.formservice'; @Component({ @@ -27,11 +28,13 @@ export class UserFormRolesComponent implements OnInit { public readonly UserFormService = UserFormService; public readonly validationErrorId: string = generateValidationErrorId(); + public isValid: boolean = true; ngOnInit(): void { const control: AbstractControl = this.formGroupParent.controls[UserFormService.CLIENT_ROLES]; this.invalidParams$ = control.statusChanges.pipe( map((status: FormControlStatus) => (status === 'INVALID' ? Object.values(control.errors) : [])), + tap((invalidParams: InvalidParam[]) => (this.isValid = isEmpty(invalidParams))), ); } } -- GitLab From 21337e8ce70ecc6a2871ef4225b1ca7165376082 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Tue, 18 Mar 2025 19:40:42 +0100 Subject: [PATCH 20/22] OZG-7591 add tests --- .../user-form-roles.component.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 bc6c77336f..5f21edb7a5 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 @@ -62,6 +62,34 @@ describe('UserFormRolesComponent', () => { control.setErrors(null); }); + + it('should mark as invalid', (done) => { + const invalidParam: InvalidParam = createInvalidParam(); + const error: any = { dummy: invalidParam }; + const control: AbstractControl = new FormControl(); + component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control }); + + component.ngOnInit(); + component.invalidParams$.subscribe(() => { + expect(component.isValid).toBe(false); + done(); + }); + + control.setErrors(error); + }); + + it('should mark as valid', (done) => { + const control: AbstractControl = new FormControl(); + component.formGroupParent = new UntypedFormGroup({ [UserFormService.CLIENT_ROLES]: control }); + + component.ngOnInit(); + component.invalidParams$.subscribe(() => { + expect(component.isValid).toBe(true); + done(); + }); + + control.setErrors(null); + }); }); }); -- GitLab From ea1124f548d3935e9d43946619f86c0240a15cf7 Mon Sep 17 00:00:00 2001 From: Oliver Schmidt <kontakt@webkreation.de> Date: Fri, 21 Mar 2025 12:47:02 +0100 Subject: [PATCH 21/22] OZG-7591 fix error massage break --- .../src/lib/form/error-message/error-message.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts index 8c33dd8452..7e8cc5ae0e 100644 --- a/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/error-message/error-message.component.ts @@ -32,7 +32,7 @@ import { ExclamationIconComponent } from '../../icons/exclamation-icon/exclamati imports: [CommonModule, ExclamationIconComponent], styles: [':host {@apply flex text-error my-2 text-sm items-center font-medium}'], template: `<ods-exclamation-icon class="mr-1"></ods-exclamation-icon> - <div class="flex-grow break-all" [id]="id"> + <div class="flex-grow break-words" [id]="id"> {{ text }} <br *ngIf="subText" aria-hidden="true" /> {{ subText }} -- GitLab From 4f427a735744a3117c6f6d9f08418d63ffcc1335 Mon Sep 17 00:00:00 2001 From: sebo <sebastian.bergandy@external.mgm-cp.com> Date: Mon, 24 Mar 2025 12:59:33 +0100 Subject: [PATCH 22/22] OZG-7591 fix e2e --- .../benutzer_rollen/benutzer-anlegen.cy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts index 9caecfb235..e659bf8dde 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer-anlegen.cy.ts @@ -185,15 +185,15 @@ describe('Benutzer anlegen', () => { it('should have user created', () => { benutzerHelper.openNewBenutzerPage(); - benutzerHelper.addBenutzer(newUser); + benutzerHelper.addBenutzer(newAdminUser); benutzerHelper.saveBenutzer(); - benutzerVerifier.verifyUserInList(newUser); + benutzerVerifier.verifyUserInList(newAdminUser); }); it('should show validation error on existing email', () => { benutzerHelper.openNewBenutzerPage(); - benutzerHelper.addBenutzer(newUser); + benutzerHelper.addBenutzer(newAdminUser); benutzerHelper.saveBenutzer(); benutzerVerifier.verifyEmailExistsError(); @@ -201,17 +201,17 @@ describe('Benutzer anlegen', () => { it('should show validation error on existing username', () => { benutzerHelper.openNewBenutzerPage(); - benutzerHelper.addBenutzer({ ...newUser, email: faker.internet.email() + '.local' }); + benutzerHelper.addBenutzer({ ...newAdminUser, email: faker.internet.email() + '.local' }); benutzerHelper.saveBenutzer(); benutzerVerifier.verifyUsernameExistsError(); }); it('should remove benutzer', () => { - benutzerHelper.openBenutzerPage(newUser.username); - benutzerHelper.deleteBenutzer(newUser.username); + benutzerHelper.openBenutzerPage(newAdminUser.username); + benutzerHelper.deleteBenutzer(newAdminUser.username); - benutzerVerifier.verifyUserNotInList(newUser.username); + benutzerVerifier.verifyUserNotInList(newAdminUser.username); }); }); }); -- GitLab