diff --git a/alfa-client/Jenkinsfile.e2e b/alfa-client/Jenkinsfile.e2e index 3795c981db2f242b0ddf7458f0f4ab65d2ff09e9..ebde17972347d5b54e42ab4607728a852ac91b0e 100644 --- a/alfa-client/Jenkinsfile.e2e +++ b/alfa-client/Jenkinsfile.e2e @@ -798,7 +798,7 @@ String generateCypressConfig(String bezeichner, String appName, String appVarian String generateUrlBezeichner(String bezeichner, String appName) { if (appName == 'admin-e2e') { - return "${bezeichner}-admin"; + return "${bezeichner}-administration"; } return bezeichner; } 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 673ea2c47571873cc69795547f6dc5359609e99a..c80f8d4b5056e16a6de0d772cfd6df4e8e02b45e 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-e2-e.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 = '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 adminCheckboxLabel: string = 'Admin'; private readonly loeschenCheckboxLabel: string = 'Löschen'; private readonly userCheckboxLabel: string = 'User'; @@ -111,6 +107,13 @@ export class BenutzerE2EComponent { private readonly saveButton: string = 'save-button'; private readonly deleteButton: string = 'delete-button'; + 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); } @@ -120,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 { @@ -166,6 +169,26 @@ export class BenutzerE2EComponent { public getDeleteButton(): Cypress.Chainable<Element> { return cy.getTestElement(this.deleteButton); } + + public getVornameTextFeld(): TextEditorE2EComponent { + return this.vornameTextFeld; + } + + public getNachnameTextFeld(): TextEditorE2EComponent { + return this.nachnameTextFeld; + } + + public getBenutzernameTextFeld(): TextEditorE2EComponent { + return this.benutzernameTextFeld; + } + + public getEmailTextFeld(): TextEditorE2EComponent { + return this.emailTextFeld; + } + + public getRollenValidationError(): Cypress.Chainable<Element> { + return cy.getTestElement(this.rollenValidationError); + } } export class BenutzerDeleteDialogE2EComponent { diff --git a/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..0605779543307d829c2fc65dc3380643cc519bba --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/components/ods/text-editor-e2-e.component.ts @@ -0,0 +1,19 @@ +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.input); + } + + public getErrorMessage(): Cypress.Chainable<Element> { + return cy.getTestElement(this.validationError); + } +} 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 6869eafd381b1d9df0dde4b213466d4c8db8b4db..e659bf8dde0d751c2f2af41860eb90944aadf1fb 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 @@ -122,4 +122,96 @@ describe('Benutzer anlegen', () => { benutzerHelper.deleteBenutzer(newAdminUser.username); }); }); + + describe('when filling formular', () => { + it('should show validation errors on empty formular', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyEmptyVornameError(); + benutzerVerifier.verifyEmptyNameError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); + }); + + it('should hide Vorname validation error on filled', () => { + benutzerHelper.editVorname('Max'); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyEmptyNameError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); + }); + + it('should hide Nachname validation error on filled', () => { + benutzerHelper.editNachname('Mustermann'); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameLengthError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); + }); + + it('should hide Benutzername validation error on filled', () => { + benutzerHelper.editBenutzername('Max'); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyInvalidEmailError(); + benutzerVerifier.verifyEmptyRollenError(); + }); + + it('should hide Email validation error on filled', () => { + benutzerHelper.editEmail('max@max.local'); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyEmailHasNoError(); + benutzerVerifier.verifyEmptyRollenError(); + }); + + it('should hide Rollen validation error on filled', () => { + benutzerHelper.addUserRole(); + benutzerVerifier.verifyVornameHasNoError(); + benutzerVerifier.verifyLastNameHasNoError(); + benutzerVerifier.verifyUsernameHasNoError(); + benutzerVerifier.verifyEmailHasNoError(); + benutzerVerifier.verifyRollenHasNoError(); + }); + }); + + describe('after formular submission', () => { + it('should have user created', () => { + benutzerHelper.openNewBenutzerPage(); + + benutzerHelper.addBenutzer(newAdminUser); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyUserInList(newAdminUser); + }); + + it('should show validation error on existing email', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.addBenutzer(newAdminUser); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyEmailExistsError(); + }); + + it('should show validation error on existing username', () => { + benutzerHelper.openNewBenutzerPage(); + benutzerHelper.addBenutzer({ ...newAdminUser, email: faker.internet.email() + '.local' }); + benutzerHelper.saveBenutzer(); + + benutzerVerifier.verifyUsernameExistsError(); + }); + + it('should remove benutzer', () => { + benutzerHelper.openBenutzerPage(newAdminUser.username); + benutzerHelper.deleteBenutzer(newAdminUser.username); + + benutzerVerifier.verifyUserNotInList(newAdminUser.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 ec1bbb05e560d3c45de062711da3da9ed8cf39c1..d6a839a2c755d0e43ab6608f1a8dcb60b57855b0 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 @@ -34,6 +34,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 0c0ad0c26492851788cb31352522ac0244f4217f..daf08ce5a6fb0869a60531b9a0179e44d4ce4b72 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,6 +1,7 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; 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'; @@ -34,6 +35,26 @@ export class E2EBenutzerHelper { this.modifyBenutzer(user); } + public editVorname(vorname: string): void { + this.executor.modifyVorname(vorname); + } + + public editNachname(nachname: string): void { + this.executor.modifyNachname(nachname); + } + + public editBenutzername(username: string): void { + this.executor.modifyBenutzername(username); + } + + public editEmail(email: string): void { + this.executor.modifyEmail(email); + } + + public addUserRole(): void { + this.executor.checkUserRole(); + } + private modifyBenutzer(user: AdminUserE2E): void { this.executor.modifyBenutzer(user); } @@ -53,11 +74,13 @@ export class E2EBenutzerHelper { public saveBenutzer(): void { this.executor.saveBenutzer(); + waitForSpinnerToDisappear(); } public deleteBenutzer(userName: string): void { this.openBenutzerPage(userName); this.executor.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 277f735a6038200c0c21c6efaa8b06d71d6d9e9c..22f42fb09f6d560491cc9230cb705a3729059dec 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,10 +1,12 @@ +import { EMPTY_STRING } from '@alfa-client/tech-shared'; import { BenutzerE2EComponent, BenutzerListE2EComponent, BenutzerListItemE2EComponent, } from '../../components/benutzer/benutzer.e2e.component'; +import { E2EBenutzerValidationMessages } from '../../model/benutzer.messages'; import { AdminUserE2E } from '../../model/util'; -import { contains, exist, notBeEnabled, 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 { @@ -67,4 +69,62 @@ export class E2EBenutzerVerifier { private getBenutzerItem(userName: string): BenutzerListItemE2EComponent { return this.benutzerListPage.getItem(userName); } + + public verifyEmptyVornameError(): void { + exist(this.benutzerPage.getVornameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.VORNAME_EMPTY_ERROR); + } + + public verifyEmptyNameError(): void { + exist(this.benutzerPage.getNachnameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.NACHNAME_EMPTY_ERROR); + } + + public verifyUsernameLengthError(): void { + exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); + haveText( + this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), + E2EBenutzerValidationMessages.BENUTZERNAME_SIZE_ERROR, + ); + } + + public verifyUsernameExistsError(): void { + exist(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.BENUTZER_NAME_EXISTS); + } + + public verifyInvalidEmailError(): void { + exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_INVALID_ERROR); + } + + public verifyEmailExistsError(): void { + exist(this.benutzerPage.getEmailTextFeld().getErrorMessage()); + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), E2EBenutzerValidationMessages.EMAIL_EXISTS); + } + + public verifyEmptyRollenError(): void { + exist(this.benutzerPage.getRollenValidationError()); + contains(this.benutzerPage.getRollenValidationError(), E2EBenutzerValidationMessages.ROLLEN_EMPTY_ERROR); + } + + public verifyVornameHasNoError(): void { + haveText(this.benutzerPage.getVornameTextFeld().getErrorMessage(), EMPTY_STRING); + } + + public verifyLastNameHasNoError(): void { + haveText(this.benutzerPage.getNachnameTextFeld().getErrorMessage(), EMPTY_STRING); + } + + public verifyUsernameHasNoError(): void { + haveText(this.benutzerPage.getBenutzernameTextFeld().getErrorMessage(), EMPTY_STRING); + } + + public verifyEmailHasNoError(): void { + haveText(this.benutzerPage.getEmailTextFeld().getErrorMessage(), EMPTY_STRING); + } + + public verifyRollenHasNoError(): void { + 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 new file mode 100644 index 0000000000000000000000000000000000000000..db6aa9f09f5f2115a1de2e6295b564ea845c4aa8 --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/model/benutzer.messages.ts @@ -0,0 +1,9 @@ +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', + 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', +} diff --git a/alfa-client/apps/admin/package.json b/alfa-client/apps/admin/package.json index d124f972122f91564b8d04044ab8541beb8b4ae6..65a6ef5178180d831904cfd25ca58ae832164385 100644 --- a/alfa-client/apps/admin/package.json +++ b/alfa-client/apps/admin/package.json @@ -1,4 +1,4 @@ { "name": "admin", - "version": "1.7.0-SNAPSHOT" + "version": "1.8.0-SNAPSHOT" } diff --git a/alfa-client/apps/alfa/package.json b/alfa-client/apps/alfa/package.json index db8d098a8244e0cfde3ebcd7f8e148c07ffd9316..d2b0c691be9b12310cb1f527368dbf97b1ac764e 100644 --- a/alfa-client/apps/alfa/package.json +++ b/alfa-client/apps/alfa/package.json @@ -1,4 +1,4 @@ { "name": "alfa", - "version": "2.22.0-SNAPSHOT" + "version": "2.23.0-SNAPSHOT" } diff --git a/alfa-client/apps/info/package.json b/alfa-client/apps/info/package.json index f7b9ea61038da3ea7801ac1121b57c21816c49a6..b0116c864ac85254663de4319855171ec1183e33 100644 --- a/alfa-client/apps/info/package.json +++ b/alfa-client/apps/info/package.json @@ -1,4 +1,4 @@ { "name": "info", - "version": "1.7.0-SNAPSHOT" + "version": "1.8.0-SNAPSHOT" } diff --git a/alfa-client/libs/admin/keycloak-shared/src/index.ts b/alfa-client/libs/admin/keycloak-shared/src/index.ts index 4197ca2f1a4afbeacb65191a3fbe565c72987da5..06df4b1a3e6c7b8508c6d3a1e6ae4e55844020da 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.model.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..87bdce2f2d937a86c9358ddd751b5b1c36c59cd4 --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.model.ts @@ -0,0 +1,31 @@ +export const KEYCLOAK_VALIDATION_ERROR_STATUS_CODES: 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[]; +} diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..29c1aea25abe2703d3f1feecba2be25d470e8faf --- /dev/null +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.spec.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from '@jest/globals'; +import { + ErrorRepresentation, + 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', () => { + 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.util.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/keycloak-error.util.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f8cf30cada65b7045f7625bf301fa826bec902b --- /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 6fe0a332ba918b071d6bddfb712e7b4e177a4891..038187decc6f18d2bac42ab33d94cdf3a751912a 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,43 @@ * 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 { 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'; -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, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model'; +import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util'; + +jest.mock('./keycloak-error.util', () => ({ + ...jest.requireActual('./keycloak-error.util'), + 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 +76,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,10 +213,29 @@ describe('KeycloakFormService', () => { beforeEach(() => { service._doSubmit = jest.fn().mockReturnValue(singleHot(dummyStateResource)); + 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(); }); @@ -199,6 +245,424 @@ describe('KeycloakFormService', () => { expect(submitResponse).toBeObservable(singleCold(dummyStateResource)); }); + + describe('on server validation error', () => { + const keycloakHttpError: KeycloakHttpErrorResponse = createKeycloakHttpErrorResponse(); + + beforeEach(() => { + service._doSubmit = jest.fn().mockReturnValue(throwError(() => keycloakHttpError)); + }); + + it('should process response validation errors', () => { + service.submit().subscribe(); + + expect(service._processResponseValidationErrors).toHaveBeenCalledWith(keycloakHttpError); + }); + + it('should return processing response validation errors result', () => { + expect(service.submit()).toBeObservable(singleColdCompleted(createEmptyStateResource())); + }); + }); + }); + + describe('process invalid form', () => { + beforeEach(() => { + service._showValidationErrorForAllInvalidControls = jest.fn(); + }); + + it('should show validation errors on all invalid controls', () => { + service._processInvalidForm(); + + 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(); + + 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; + }); + + 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; + }); + + 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._showValidationErrorForAllInvalidControls(control); + + 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, + }); + + service._showValidationErrorForAllInvalidControls(group); + + 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(); + }); + }); + + 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 +770,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 49a2e6c84e3fdbf69b22ca324ee0100a34ae6763..e1856df17c3952c3fde88e15d205b6838b9083ff 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,15 @@ * 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, 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 { first, Observable, of, tap } 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 { ErrorRepresentation, KeycloakErrorMessage, KeycloakFieldName, KeycloakHttpErrorResponse } from './keycloak-error.model'; +import { extractErrorRepresentations, isFieldErrorMessage, isSingleErrorMessage } from './keycloak-error.util'; @Injectable() export abstract class KeycloakFormService<T> { @@ -70,9 +72,127 @@ export abstract class KeycloakFormService<T> { } public submit(): Observable<StateResource<T>> { - return this._doSubmit(); + if (this.form.invalid) { + return this._processInvalidForm(); + } + return this._doSubmit().pipe( + 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); + }); + } + + _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>>; _patch(valueToPatch: T): void { 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 3928e72cc9b20e768c905eedb69dc48c4ecfba3b..99426f1a908aa57d206ebc0d9880d1d20b08bcd8 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,13 +21,13 @@ * 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 { 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'; @@ -211,6 +211,13 @@ describe('KeycloakResourceService', () => { expect(service.refresh).toHaveBeenCalled(); }); + it('should rethrow error', () => { + const error = new Error('dummy'); + service.refresh = jest.fn(); + + expect(service._refreshAfterEmit(throwError(() => error))).toBeObservable(cold('#', null, error)); + }); + it('should throw error refresh on error', () => { service.refresh = jest.fn(); 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 2b6255aa9bba1e4ac1424de5a9b56282cb683349..9e8f1f7828f030c702b617414ff8f46ad0de23dc 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 @@ -78,11 +78,14 @@ export abstract class KeycloakResourceService<T> { return action.pipe( first(), tap((): void => this.refresh()), - catchError((err: Error) => this.handleError(err)), + catchError((err) => { + this.refresh(); + return this._handleError(err); + }), ); } - handleError(err: Error): Observable<never> { + _handleError(err: Error): Observable<never> { this.refresh(); return throwError(() => err); } 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 c0ef6b387b57946efc4336573809b55582b7912e..8508ad1aad9980fca4502a94f84a2591c77bf814 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 @@ -36,7 +36,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'; import { KeycloakDefaults } from './keycloak.model'; @@ -92,16 +91,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', () => { @@ -133,16 +122,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 c510b595f5b6157f20093328d23fa31bcc11e242..da9b890518ef470364c4a30953894c255a328e4b 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 @@ -31,11 +31,12 @@ import MappingsRepresentation from '@keycloak/keycloak-admin-client/lib/defs/map import { RequiredActionAlias } from '@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation'; 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 { concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs'; import { KeycloakDefaults } from './keycloak.model'; +import * as _ from 'lodash-es'; + @Injectable({ providedIn: 'root', }) @@ -53,7 +54,6 @@ export class UserRepository { }), tap((response: { id: string }): void => this._sendActivationMail(response.id)), map((): User => user), - catchError((err: Error): Observable<never> => this._handleCreateError(err)), ); } @@ -65,7 +65,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/keycloak-shared/src/test/keycloak.ts b/alfa-client/libs/admin/keycloak-shared/src/test/keycloak.ts new file mode 100644 index 0000000000000000000000000000000000000000..32fdb0792d197579367961070e189301a786d222 --- /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-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 830c7960ac97bb94267cdb6737d3a83001e44951..fb1574bf9b8090e385af452584610fb4d03f457f 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,17 +1,18 @@ <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.FIRST_NAME" [isRequired]="true" label="Vorname" dataTestId="firstName" /> + <ods-text-editor [formControlName]="UserFormService.LAST_NAME" [isRequired]="true" label="Nachname" dataTestId="lastName" /> @if (isPatch) { - <admin-user-form-data-name [userName]="userName" data-test-id="user-name-info" /> + <admin-user-form-data-name [userName]="userName" data-test-id="user-name-info" dataTestId="username" /> } @else { <ods-text-editor [formControlName]="UserFormService.USERNAME" [isRequired]="true" label="Benutzername" data-test-id="user-name-editor" + dataTestId="username" /> } - <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" /> + <ods-text-editor [formControlName]="UserFormService.EMAIL" [isRequired]="true" label="E-Mail" dataTestId="email" /> </div> </div> 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 5d3fea2f35377a4c9a2a62d99d356fe38bbbf7d7..2c1d3520d18708d5b7861627130f35cec586c927 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 1dda4319a297a47b72c1d1c8bc6e1401a796a7ee..d3d39a71ceb69e639c38e1ac0219f27eb1c5e6f5 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,6 +1,12 @@ -<h2 class="heading-2 mt-4">Rollen für OZG-Cloud</h2> -<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"> @@ -54,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.spec.ts b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.spec.ts index b27337b2786891b5738a6d09bae607cecd492cdb..5f21edb7a5722e98bd9a0069b3dcb3f9ce8ad10a 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,84 @@ 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); + }); + + 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); + }); + }); + }); + + 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 c7d46e948f2ddc5b4c50fd5880ddf7ff2221030c..7a8c901dab035b8a8364b9ce60c1453a7374711c 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,40 @@ -import { Component, Input } from '@angular/core'; -import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; -import { CheckboxEditorComponent } from '@ods/component'; +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'; +import { CheckboxEditorComponent, ValidationErrorComponent } from '@ods/component'; import { InfoIconComponent, TooltipDirective } from '@ods/system'; +import { isEmpty } from 'lodash-es'; +import { map, Observable, of, tap } from 'rxjs'; 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; + 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))), + ); + } } 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 99e28aabc946516a2076a89eb850c1df7b5594cd..caabe30992d06bce0dffefa1c988f2e405a8793a 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,9 +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 { 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 { + 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'; @@ -34,24 +39,24 @@ import { AbstractControl, FormControl, FormGroup, UntypedFormBuilder, UntypedFor import { ActivatedRoute, UrlSegment } from '@angular/router'; import { faker } from '@faker-js/faker/locale/de'; import { expect } from '@jest/globals'; -import { cold } from 'jest-marbles'; 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 { 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>; @@ -63,7 +68,7 @@ describe('UserFormService', () => { ]); beforeEach(() => { - service = { + userService = { ...mock(UserService), refresh: jest.fn(), create: jest.fn(), @@ -83,7 +88,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 }, @@ -91,15 +96,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', () => { @@ -108,7 +113,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 }); }); @@ -118,7 +123,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 }); }); @@ -130,17 +135,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)); }); @@ -148,65 +153,63 @@ 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(() => { - 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(); }); @@ -214,53 +217,37 @@ 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 }); }); }); - describe('roleValidator', () => { - it('should return error if no role is selected', () => { - const result = formService.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 = formService.roleValidator()(roleGroup); - - expect(result).toBeNull(); - }); - }); - 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); }); @@ -268,7 +255,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); }); @@ -279,7 +266,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(false); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(true); }); @@ -288,7 +275,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(true); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(false); }); @@ -299,7 +286,7 @@ describe('UserFormService', () => { const control: AbstractControl = alfaGroup.get(UserFormService.LOESCHEN); control.setValue(false); - formService._disableUncheckedCheckboxes(alfaGroup); + service._disableUncheckedCheckboxes(alfaGroup); expect(control.disabled).toBe(true); }); @@ -310,7 +297,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(); }); @@ -320,63 +307,44 @@ 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._doSubmit().subscribe(); - 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._doSubmit().subscribe(); - 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(); - tick(); + service._doSubmit().subscribe(); 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', () => { @@ -387,57 +355,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.'); })); @@ -445,7 +406,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([]); }); @@ -453,7 +414,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]); }); @@ -461,7 +422,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([]); }); @@ -470,7 +431,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]); }); @@ -478,29 +439,65 @@ 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(); }); 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(); }); }); 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 666d4d192605be0c6e14cca6008225b86570e1d5..b0cb7867c9cd8330c274fd9ee944d16f9a5507bb 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,14 +24,29 @@ import { AdminOrganisationsEinheit, AdminOrganisationsEinheitService } from '@admin-client/organisations-einheit-shared'; import { ROUTES } from '@admin-client/shared'; import { AdminRoles, User, UserService } from '@admin-client/user-shared'; -import { KeycloakFormService, PatchConfig } from '@admin/keycloak-shared'; +import { + KeycloakErrorMessage, + KeycloakFieldName, + 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 { + 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 { catchError, filter, Observable, of, Subscription, tap } from 'rxjs'; +import { filter, Observable, Subscription, tap } from 'rxjs'; @Injectable() export class UserFormService extends KeycloakFormService<User> implements OnDestroy { @@ -86,10 +101,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 +119,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( @@ -190,16 +191,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()), ); } @@ -220,11 +216,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(), @@ -265,4 +256,33 @@ 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( + this.isPatch() ? 'Der Benutzer konnte nicht gespeichert werden.' : 'Der Benutzer konnte nicht angelegt werden.', + ); + } } diff --git a/alfa-client/libs/design-component/src/index.ts b/alfa-client/libs/design-component/src/index.ts index 9eec827c3a019115cc42c751521816fdde5c87a7..27e3b6381b8dc83a99533d4440b7fac8845e9f48 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'; 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 2533dc1ed4b63eec0da6aac3d7969a8b34172740..1cc8b07470d8086854cee7b310342c8697b4eed5 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 dfab859671455c3354bd14297d069ffde5474cde..bbb9f693fc54a9760f0bb33b006848b12aba6554 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 c4c009107436b4b7a8837c0fa9c31c4add1008d6..3c116fb0b27c3f1b3b24807e7a0e26affb514b00 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 45f7c42c8f3f609567e89d3075411ac652eb8882..19ae4d4d435edb97ec43d2512eda5f6ab371c7aa 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 39ac3109643ea6771e5199ba3e9bba49cef69c37..753a2c385a768d890c272847d220b89f8de34c9e 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 c7032ef03b58627369f4857d0b2bda4546d092b5..9203da053f1a13a071244c009c6516c213de5434 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 224dbe8cc5d73df87e45d77700b0ffca1efdb03e..cb566bafe0c5a5a804ae3e10491c0ca19faf4c40 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 8ae6e949f8ed75b49c309609d0ff9419acb46582..fca8d2a2ecfbec56310a3ec835f03459edb4ec27 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'; @@ -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 ffadf796c9031418fda5463896a1452351f2e109..3b3750bd3174bfaeecf2821d4dc29d9e5c6a4d4e 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 66ed08e7f973dad14e092c899de2880ff7d8f515..7e8cc5ae0ed07c1d6de152f018842bb9f05ab11c 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-words" [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 1740d7ba39080e17caa742c931fa9267bffaf0a0..c84948d1e2db9eba1d7cd0071f193f1010ad825e 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 @@ -55,11 +55,12 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; template: ` <div class="relative"> <label *ngIf="showLabel" [for]="id" class="text-md mb-2 block font-medium text-text"> - {{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> + {{ inputLabel }} + <ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> </label> <div *ngIf="withPrefix" class="pointer-events-none absolute bottom-2 left-2 flex size-6 items-center justify-center"> - <ng-content select="[prefix]" /> + <ng-content select="[prefix]"/> </div> <input type="text" @@ -71,11 +72,12 @@ 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]" /> + <ng-content select="[suffix]"/> </div> <ng-content select="[error]"></ng-content> @@ -102,6 +104,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 f3eb84af9d638567a3bcbdc644b400abf344643e..d3c04b6c82bb491b5f25652bd89e6af37595dd85 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/index.ts b/alfa-client/libs/tech-shared/src/index.ts index be9ebae3ab1b5b0b13a375f8bd097acbfa8c11e8..9220218eb9d7b86d8887d417285f0dac63a9d9eb 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.validation.messages.ts b/alfa-client/libs/tech-shared/src/lib/validation/tech.validation.messages.ts index accb4de66726417404f156a36d8b732731199c50..2045e5afa6740ac78ea86e2a9057d3ea9066122a 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', }; 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 638689f2ba8424676c130164749530083b7e41e4..0701d11447b842b06de8d18e9798c117a61b6f1f 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'; @@ -114,3 +114,7 @@ export function _mapFormArrayElementNameToPath(name: string): string { const formArrayControlIndexCaptureGroup: RegExp = /\[(\d+?)]\./g; return name.replace(formArrayControlIndexCaptureGroup, '.$1.'); } + +export function generateValidationErrorId(): string { + return `${uniqueId()}-validation-error`; +} 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 0000000000000000000000000000000000000000..00ef101f7dd00dde3d7c6452d5dc771cc41d0c6f --- /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 0000000000000000000000000000000000000000..798a98d8ebe415a7d9e97cae7c6bd6000a79926b --- /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; +}