diff --git a/.vscode/settings.json b/.vscode/settings.json index 02ffa78fc20462c7539afdc7ef4cc8b7b63bdd1b..9b1e5446992d170181e31942d75d020e12bbe5d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "angular.enable-strict-mode-prompt": false, - "java.debug.settings.onBuildFailureProceed": true + "java.debug.settings.onBuildFailureProceed": true, + "java.compile.nullAnalysis.mode": "automatic" } \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index c28bf0d2fa1f10b024edee98db52ddecd73f2b6b..deb6ce4266ec36519843962e4f378ed44768e381 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -85,6 +85,27 @@ pipeline { // } } + stage('Build and push client container') { + steps { + script { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + dir('alfa-client') { + IMAGE_TAG = generateImageTag() + + sh 'npm run ci-build-alfa-client-container' + + withCredentials([usernamePassword(credentialsId: 'jenkins-nexus-login', usernameVariable: 'USER', passwordVariable: 'PASSWORD')]) { + sh 'docker login docker.ozg-sh.de -u ${USER} -p ${PASSWORD}' + + sh "docker tag docker.ozg-sh.de/alfa-client:build-latest docker.ozg-sh.de/alfa-client:${IMAGE_TAG}" + sh "docker push docker.ozg-sh.de/alfa-client:${IMAGE_TAG}" + } + } + } + } + } + } + stage('Set Version') { when { not { @@ -227,22 +248,43 @@ pipeline { } } - stage ('OWASP Dependency-Check Vulnerabilities') { + stage ('Deploy SBOM to DependencyTrack') { + steps { + script { + IMAGE_TAG = generateImageTag() + + configFileProvider([configFile(fileId: 'maven-settings', variable: 'MAVEN_SETTINGS')]) { + withCredentials([string(credentialsId: 'dependency-track-api-key', variable: 'API_KEY')]) { + + dir('alfa-server') { + catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { + sh "mvn --no-transfer-progress -s $MAVEN_SETTINGS io.github.pmckeown:dependency-track-maven-plugin:upload-bom -Ddependency-track.apiKey=$API_KEY -Ddependency-track.projectVersion=${IMAGE_TAG} -Ddependency-track.dependencyTrackBaseUrl=https://dependency-track.ozg-sh.de" + } + } + } + } + } + } + } + + stage ('Trigger Barrierefreiheit Rollout') { + when { + branch 'barrierefreiheit-dev' + } steps { - dependencyCheck additionalArguments: ''' - -o "./" - -s "./" - -f "ALL" - -d /dependency-check-data - --suppression dependency-check-supressions.xml - --disableKnownExploited - --noupdate - --disableArchive - --prettyPrint''', odcInstallation: 'dependency-check-owasp' - - dependencyCheckPublisher pattern: 'dependency-check-report.xml' + script { + FAILED_STAGE = env.STAGE_NAME + + cloneGitopsRepo() + + setNewBarrierefreiheitVersion() + + pushGitopsRepo() + + } } } + } post { failure { @@ -274,7 +316,10 @@ String generateHelmChartVersion() { def chartVersion = "${VERSION}" if (isMasterBranch()) { - chartVersion += "-${env.GIT_COMMIT.take(7)}" + chartVersion += getCommitHash() + } + else if (isBarrierefreiheitBranch()) { + chartVersion += "-barrierefreiheit${getCommitHash()}" } else if (!isReleaseBranch()) { chartVersion += "-${env.BRANCH_NAME}" @@ -295,8 +340,8 @@ Void tagAndPushDockerImage(String newTag){ String generateImageTag() { def imageTag = "${env.BRANCH_NAME}-${VERSION}" - if (isMasterBranch()) { - imageTag += "-${env.GIT_COMMIT.take(7)}" + if (isMasterBranch() || isBarrierefreiheitBranch()) { + imageTag += getCommitHash() } return imageTag @@ -365,9 +410,19 @@ Void setNewTestVersion() { } Void setNewGitopsVersion(String environment) { - dir("gitops") { - def envFile = "${environment}/application/values/alfa-values.yaml" + def envFile = "${environment}/application/values/alfa-values.yaml" + def commitMessage = "jenkins rollout ${environment} alfa version ${IMAGE_TAG}"; + setNewGitopsVersion(envFile, commitMessage); +} + +Void setNewBarrierefreiheitVersion() { + def envFile = "dev/namespace/namespaces/by-barrierefreiheit-dev.yaml" + def commitMessage = "jenkins rollout ${IMAGE_TAG} for Barrierefreiheit Dev" + setNewGitopsVersion(envFile, commitMessage); +} +Void setNewGitopsVersion(String envFile, String commitMessage) { + dir("gitops") { def envVersions = readYaml file: envFile envVersions.alfa.image.tag = IMAGE_TAG @@ -375,15 +430,19 @@ Void setNewGitopsVersion(String environment) { writeYaml file: envFile, data: envVersions, overwrite: true - if (hasValuesFileChanged(environment)) { + if (hasValuesFileChanged(envFile)) { sh "git add ${envFile}" - sh "git commit -m 'jenkins rollout ${environment} alfa version ${IMAGE_TAG}'" + sh "git commit -m '${commitMessage}'" } } } -Boolean hasValuesFileChanged(String environment) { - return sh (script: "git status | grep '${environment}/application/values/alfa-values.yaml'", returnStatus: true) == env.SH_SUCCESS_STATUS_CODE as Integer +String getCommitHash() { + return "-${env.GIT_COMMIT.take(7)}"; +} + +Boolean hasValuesFileChanged(String envFile) { + return sh (script: "git status | grep '${envFile}'", returnStatus: true) == env.SH_SUCCESS_STATUS_CODE as Integer } Boolean isReleaseBranch() { @@ -394,6 +453,10 @@ Boolean isMasterBranch() { return env.BRANCH_NAME == 'master' } +Boolean isBarrierefreiheitBranch() { + return env.BRANCH_NAME == 'barrierefreiheit-dev' +} + Boolean isReleaseVersion(List versions) { return matchRegexVersion(versions, RELEASE_REGEX) } diff --git a/alfa-client/apps/admin/src/app/app.component.html b/alfa-client/apps/admin/src/app/app.component.html index 87dbc4de9c89698e3ba41a8f9abb26db5ebb4f05..adbd608ba83644365146d6b241a20848e473f065 100644 --- a/alfa-client/apps/admin/src/app/app.component.html +++ b/alfa-client/apps/admin/src/app/app.component.html @@ -1,22 +1,28 @@ <ng-container *ngIf="(apiRootStateResource$ | async)?.resource as apiRoot"> - <header class="flex items-center justify-between bg-white p-6" data-test-id="admin-header"> - <div class="font-extrabold text-ozgblue">OZG-Cloud Administration</div> - <div> - <user-profile-button-container - data-test-id="user-profile-button" - ></user-profile-button-container> - </div> + <header + class="flex h-16 items-center justify-between border-b border-b-ozggray-300 bg-white px-9 py-2" + data-test-id="admin-header" + > + <a + class="rounded border-2 border-transparent p-1 outline-2 outline-offset-2 hover:border-primary focus-visible:border-gray-200 focus-visible:outline-focus" + aria-label="OZG-Cloud Administration" + routerLink="/" + data-test-id="logo-link" + > + <ods-admin-logo-icon /> + </a> + <user-profile-button-container + data-test-id="user-profile-button" + ></user-profile-button-container> </header> - <div class="flex w-full flex-auto justify-center overflow-y-auto"> - <div class="w-72 bg-slate-100 p-6"> - <nav> - <admin-navigation - *ngIf="apiRoot | hasLink: ApiRootLinkRel.CONFIGURATION" - data-test-id="navigation" - ></admin-navigation> - </nav> - </div> - <main class="flex-auto overflow-y-auto bg-slate-200 p-6"> + <div class="flex h-screen w-full justify-center overflow-y-auto"> + <nav class="h-full w-72 bg-slate-100 p-4"> + <admin-navigation + *ngIf="apiRoot | hasLink: ApiRootLinkRel.CONFIGURATION" + data-test-id="navigation" + ></admin-navigation> + </nav> + <main class="flex-1 overflow-y-auto bg-white px-6 py-4"> <router-outlet *ngIf=" apiRoot | hasLink: ApiRootLinkRel.CONFIGURATION; @@ -29,5 +35,5 @@ </ng-template> </main> </div> - <span data-test-id="build-version">Version: {{ apiRoot.version }}</span> + <footer data-test-id="build-version">Version: {{ apiRoot.version }}</footer> </ng-container> diff --git a/alfa-client/apps/admin/src/app/app.component.spec.ts b/alfa-client/apps/admin/src/app/app.component.spec.ts index a8dfa9a539e491d0e62a2b1e76170d08911315e9..d46fcd163a436a5334ed8371f003c6dcc7836194 100644 --- a/alfa-client/apps/admin/src/app/app.component.spec.ts +++ b/alfa-client/apps/admin/src/app/app.component.spec.ts @@ -6,19 +6,20 @@ import { } from '@alfa-client/tech-shared'; import { Mock, + dispatchEventFromFixture, existsAsHtmlElement, getElementFromFixture, mock, notExistsAsHtmlElement, } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; +import { Router, RouterOutlet } from '@angular/router'; +import { AdminLogoIconComponent } from '@ods/system'; import { AuthenticationService } from 'authentication'; import { NavigationComponent } from 'libs/admin-settings/src/lib/navigation/navigation.component'; import { createApiRootResource } from 'libs/api-root-shared/test/api-root'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockDirective } from 'ng-mocks'; import { of } from 'rxjs'; import { UserProfileButtonContainerComponent } from '../common/user-profile-button-container/user-profile.button-container.component'; import { UnavailablePageComponent } from '../pages/unavailable/unavailable-page/unavailable-page.component'; @@ -32,6 +33,7 @@ describe('AppComponent', () => { const buildVersionSelector: string = getDataTestIdOf('build-version'); const userProfileButtonSelector: string = getDataTestIdOf('user-profile-button'); const navigationSelector: string = getDataTestIdOf('navigation'); + const logoLink: string = getDataTestIdOf('logo-link'); const routerOutletSelector: string = getDataTestIdOf('router-outlet'); const authenticationService: Mock<AuthenticationService> = { @@ -47,11 +49,12 @@ describe('AppComponent', () => { declarations: [ AppComponent, MockComponent(NavigationComponent), + MockComponent(AdminLogoIconComponent), MockComponent(UserProfileButtonContainerComponent), MockComponent(UnavailablePageComponent), HasLinkPipe, + MockDirective(RouterOutlet), ], - imports: [RouterTestingModule], providers: [ { provide: AuthenticationService, @@ -129,6 +132,21 @@ describe('AppComponent', () => { }); }); + describe('administration logo', () => { + const apiResource: ApiRootResource = createApiRootResource(); + + beforeEach(() => { + component.apiRootStateResource$ = of(createStateResource(apiResource)); + fixture.detectChanges(); + }); + + it('should navigate to start page on click', () => { + dispatchEventFromFixture(fixture, logoLink, 'click'); + + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + }); + describe('navigation', () => { beforeEach(() => {}); it('should exist if configuration link exists', () => { diff --git a/alfa-client/apps/admin/src/app/app.module.ts b/alfa-client/apps/admin/src/app/app.module.ts index d36a3ad5f33742afc7fd844df04466865873bb43..5ef0a45f15848eb7c9c327b28c8ada4d986aefc4 100644 --- a/alfa-client/apps/admin/src/app/app.module.ts +++ b/alfa-client/apps/admin/src/app/app.module.ts @@ -13,7 +13,12 @@ import { EffectsModule } from '@ngrx/effects'; import { StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreModule } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; -import { TestbtnComponent } from '@ods/system'; +import { + AdminLogoIconComponent, + LogoutIconComponent, + PopupComponent, + PopupListItemComponent, +} from '@ods/system'; import { OAuthModule } from 'angular-oauth2-oidc'; import { HttpUnauthorizedInterceptor } from 'libs/authentication/src/lib/http-unauthorized.interceptor'; import { UserProfileButtonContainerComponent } from '../common/user-profile-button-container/user-profile.button-container.component'; @@ -34,7 +39,10 @@ import { appRoutes } from './app.routes'; ], imports: [ CommonModule, - TestbtnComponent, + AdminLogoIconComponent, + PopupComponent, + PopupListItemComponent, + LogoutIconComponent, RouterModule.forRoot(appRoutes), BrowserModule, BrowserAnimationsModule, @@ -44,8 +52,8 @@ import { appRoutes } from './app.routes'; TechSharedModule, StoreModule.forRoot({}), EffectsModule.forRoot(), - StoreRouterConnectingModule.forRoot(), environment.production ? [] : StoreDevtoolsModule.instrument({ connectInZone: true }), + StoreRouterConnectingModule.forRoot(), FormsModule, ReactiveFormsModule, AdminSettingsModule, diff --git a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.html b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.html index e27ec82f62a522e589002e75ed0830ed483ed079..436ac2a76728c0b0b9b9da7d5e67d8bc0765a98d 100644 --- a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.html +++ b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.html @@ -1,10 +1,18 @@ -<div class="dropdown"> - <button (click)="showDropDown()" class="dropbtn" data-test-id="drop-down-button"> - {{ currentUserInitials }} - </button> - <div id="myDropdown" class="dropdown-content"> - <span style="cursor: pointer" (click)="authenticationService.logout()" data-test-id="logout" - >Abmelden</span - > +<ods-popup buttonClass="rounded-full"> + <div + button-content + role="img" + class="flex size-9 items-center justify-center rounded-full border-2 border-transparent bg-ozggray-900 hover:border-primary" + > + <p class="font-semibold text-whitetext" data-test-id="popup-button-content"> + {{ currentUserInitials }} + </p> </div> -</div> + <ods-popup-list-item + caption="Abmelden" + (itemClicked)="authenticationService.logout()" + data-test-id="popup-logout-button" + > + <ods-logout-icon icon /> + </ods-popup-list-item> +</ods-popup> diff --git a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.scss b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.scss deleted file mode 100644 index 1e97f8cf7a58771514150ec93379a467bb902e2f..0000000000000000000000000000000000000000 --- a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.scss +++ /dev/null @@ -1,23 +0,0 @@ -.dropbtn { - background-color: #666666; - color: white; - padding: 16px; - cursor: pointer; - border-radius: 30px; -} - -.dropbtn:hover, -.dropbtn:focus { - background-color: #666666; -} - -.dropdown-content { - display: none; - position: absolute; - background-color: #f1f1f1; - z-index: 1; -} - -.show { - display: block; -} diff --git a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.spec.ts b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.spec.ts index bf2390e5130e235721a9eeaacbef88f7cb49f7b9..70c324948070f3e79d92bf51dca0f8f8f577d821 100644 --- a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.spec.ts +++ b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile-button-container.component.spec.ts @@ -6,8 +6,10 @@ import { } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; +import { LogoutIconComponent, PopupComponent, PopupListItemComponent } from '@ods/system'; import { AuthenticationService } from 'authentication'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; import { UserProfileButtonContainerComponent } from './user-profile.button-container.component'; describe('UserProfileButtonContainerComponent', () => { @@ -16,13 +18,18 @@ describe('UserProfileButtonContainerComponent', () => { const authenticationService: Mock<AuthenticationService> = mock(AuthenticationService); - const dropDownButton: string = getDataTestIdOf('drop-down-button'); - const logout: string = getDataTestIdOf('logout'); + const popupButtonContent: string = getDataTestIdOf('popup-button-content'); + const popupLogoutButton: string = getDataTestIdOf('popup-logout-button'); beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [UserProfileButtonContainerComponent], - imports: [RouterTestingModule], + imports: [ + RouterTestingModule, + MockComponent(PopupComponent), + MockComponent(PopupListItemComponent), + MockComponent(LogoutIconComponent), + ], providers: [ { provide: AuthenticationService, @@ -50,29 +57,23 @@ describe('UserProfileButtonContainerComponent', () => { }); }); - describe('button', () => { - it('should call showDropDown on click', () => { - component.showDropDown = jest.fn(); - - dispatchEventFromFixture(fixture, dropDownButton, 'click'); - - expect(component.showDropDown).toHaveBeenCalled(); - }); - + describe('popup button', () => { it('should show initials', () => { - const userInitials: string = 'AV'; - component.currentUserInitials = userInitials; - + component.currentUserInitials = 'AV'; fixture.detectChanges(); - const buttonElement: HTMLElement = getElementFromFixture(fixture, dropDownButton); - expect(buttonElement.textContent.trim()).toEqual('AV'); + const popupButtonContentElement: HTMLElement = getElementFromFixture( + fixture, + popupButtonContent, + ); + + expect(popupButtonContentElement.textContent.trim()).toEqual('AV'); }); }); - describe('abmelden', () => { + describe('logout', () => { it('should call authService logout', () => { - dispatchEventFromFixture(fixture, logout, 'click'); + dispatchEventFromFixture(fixture, popupLogoutButton, 'itemClicked'); expect(authenticationService.logout).toHaveBeenCalled(); }); diff --git a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile.button-container.component.ts b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile.button-container.component.ts index e61c01de1c3797616a0fad8d69ccc375af2bb39d..4b5e3c28a54eff2f89c9328bcb1559fa9a8348eb 100644 --- a/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile.button-container.component.ts +++ b/alfa-client/apps/admin/src/common/user-profile-button-container/user-profile.button-container.component.ts @@ -4,7 +4,6 @@ import { AuthenticationService } from 'libs/authentication/src/lib/authenticatio @Component({ selector: 'user-profile-button-container', templateUrl: './user-profile-button-container.component.html', - styleUrls: ['./user-profile-button-container.component.scss'], }) export class UserProfileButtonContainerComponent implements OnInit { public currentUserInitials: string; @@ -14,8 +13,4 @@ export class UserProfileButtonContainerComponent implements OnInit { ngOnInit(): void { this.currentUserInitials = this.authenticationService.getCurrentUserInitials(); } - - public showDropDown(): void { - document.getElementById('myDropdown').classList.toggle('show'); - } } diff --git a/alfa-client/apps/admin/src/styles.scss b/alfa-client/apps/admin/src/styles.scss index 77e408aa8b560f44eda957921647fb6e0d154090..e9a497ed35ba9c4e1b93a5e8b51ffd2d909d9bd7 100644 --- a/alfa-client/apps/admin/src/styles.scss +++ b/alfa-client/apps/admin/src/styles.scss @@ -2,4 +2,4 @@ @tailwind components; @tailwind utilities; -/* You can add global styles to this file, and also import other style files */ +@import 'libs/design-system/src/lib/tailwind-preset/root.css'; diff --git a/alfa-client/apps/admin/src/test/helm/deployment_env_test.yaml b/alfa-client/apps/admin/src/test/helm/deployment_env_test.yaml index 444d80b661e454360bdad6eaa0ad76bb63ec5935..6172fd2cdf989a7c932e09c1387d7f28e23db17b 100644 --- a/alfa-client/apps/admin/src/test/helm/deployment_env_test.yaml +++ b/alfa-client/apps/admin/src/test/helm/deployment_env_test.yaml @@ -22,7 +22,7 @@ # unter der Lizenz sind dem Lizenztext zu entnehmen. # -suite: test deployment container environments +suite: test deployment container environments templates: - templates/deployment.yaml set: @@ -73,3 +73,4 @@ tests: content: name: my_test_environment_name value: "A test value" + diff --git a/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-subnavigation.ts b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-subnavigation.ts index 826cb6a09e8321d97578db6ddc61f3706874ba1c..40dc7120e89587eca1214c5b2eb673df1450a220 100644 --- a/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-subnavigation.ts +++ b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-subnavigation.ts @@ -45,6 +45,10 @@ export class VorgangSubnavigationE2EComponent { return cy.getTestElement(this.backIconButton); } + public back(): void { + this.getBackButton().click(); + } + public getAnnehmenIconButton() { return cy.getTestElement(this.annehmenIconButton); } diff --git a/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-views.e2e.component.ts b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-views.e2e.component.ts index 446c287de76ee0c62fd746f6df9241c95ed0ce92..719825fd08a222a32c30f8bafd7c70e260d403b7 100644 --- a/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-views.e2e.component.ts +++ b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-views.e2e.component.ts @@ -18,6 +18,9 @@ export class VorgangViewsE2EComponent { private readonly zuLoeschenViewItem: VorgangViewE2EComponent = new VorgangViewE2EComponent( 'Zu_Loschen', ); + private readonly ungelesenViewItem: VorgangViewE2EComponent = new VorgangViewE2EComponent( + 'Ungelesen', + ); private readonly wiedervorlagenViewItem: VorgangViewE2EComponent = new VorgangViewE2EComponent( 'Wiedervorlagen', ); @@ -54,6 +57,10 @@ export class VorgangViewsE2EComponent { return this.zuLoeschenViewItem; } + public getUngelesen(): VorgangViewE2EComponent { + return this.ungelesenViewItem; + } + public getWiedervorlagen(): VorgangViewE2EComponent { return this.wiedervorlagenViewItem; } diff --git a/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-zusammenarbeit.e2e.component.ts b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-zusammenarbeit.e2e.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..9433275b79fafd874af6f7fbef835e5ad2deb76f --- /dev/null +++ b/alfa-client/apps/alfa-e2e/src/components/vorgang/vorgang-zusammenarbeit.e2e.component.ts @@ -0,0 +1,67 @@ +import { enterWith } from '../../support/cypress.util'; + +export class VorgangZusammenarbeitE2EComponent { + private readonly anfrageButton: string = 'anfrage-erstellen-button'; + private readonly zustaendigeStelleButton: string = 'organisations-einheit-search-button'; + private readonly titelText: string = 'Titel-text-input'; + private readonly messageText: string = 'Nachricht-textarea'; + private readonly sendButton: string = 'collaboration-request-submit-button'; + private readonly cancelButton: string = 'collaboration-request-cancel-button'; + + public getAnfrageButton(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.anfrageButton); + } + + public createAnfrage(): void { + this.getAnfrageButton().click(); + } + + public getZustaendigeStelleButton(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.zustaendigeStelleButton); + } + + public searchZustaendigeStelle(): void { + this.getZustaendigeStelleButton().click(); + } + + public getStelleTitel(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.titelText); + } + + public enterTitel(text: string): void { + enterWith(this.getStelleTitel(), text); + } + + public getMessageText(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.messageText); + } + + public enterMessage(text: string): void { + enterWith(this.getMessageText(), text); + } + + public messageScrollbarIsPresent(): void { + this.getMessageText().then(($textarea) => { + const scrollHeight = $textarea[0].scrollHeight; + const clientHeight = $textarea[0].clientHeight; + + expect(scrollHeight).to.be.greaterThan(clientHeight); + }); + } + + public getSendButton(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.sendButton); + } + + public sendAnfrage(): void { + this.getSendButton().click(); + } + + public getCancelButton(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.getTestElement(this.cancelButton); + } + + public cancelAnfrage(): void { + this.getCancelButton().click(); + } +} diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-mail.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-mail.cy.ts index 8f32eb567802ce78e439567c3cdc4bcfb5c0bfb8..292ad9ca566f8a29f7be1fcbf104757c712eb5cb 100644 --- a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-mail.cy.ts +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-mail.cy.ts @@ -116,7 +116,7 @@ describe('PostfachMail', () => { return { ...buildVorgang(objectIds[2], 'VorgangWithoutPostfachId'), eingangs: [...vorgang.eingangs], - header: { serviceKonto: null }, + header: { serviceKonto: null, collaborationLevel: 0 }, }; } diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-nachricht-reply-button.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-nachricht-reply-button.cy.ts index ca29375a29c0d5ce113b82357dca681679efdad5..eca73b26f756e3a3610b82f62299ddbde612e5a3 100644 --- a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-nachricht-reply-button.cy.ts +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/postfach-mail/postfach-nachricht-reply-button.cy.ts @@ -42,6 +42,7 @@ describe('Postfach Nachricht reply button', () => { }, name: 'BayernID Vorgang', header: { + collaborationLevel: 0, serviceKonto: { type: 'BayernId', postfachAddress: [ diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-bescheid/vorgang-bescheid-automatisch-erstellen.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-bescheid/vorgang-bescheid-automatisch-erstellen.cy.ts index c2a6b888ce2b2bad7055322952440965512d27b5..8f465721f0b67a4a971702d125d5f4bba9df64a9 100644 --- a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-bescheid/vorgang-bescheid-automatisch-erstellen.cy.ts +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-bescheid/vorgang-bescheid-automatisch-erstellen.cy.ts @@ -28,7 +28,7 @@ import { registerLocaleData(localeDe, 'de', localeDeExtra); //TODO: Jenkins konfigurieren -describe.skip('Upload automatic Bescheid', () => { +describe('Upload automatic Bescheid', () => { const mainPage: MainPage = new MainPage(); const vorgangList: VorgangListE2EComponent = mainPage.getVorgangList(); @@ -77,7 +77,7 @@ describe.skip('Upload automatic Bescheid', () => { dropCollections(); }); - describe.skip('Upload automatic Bescheid document', () => { + describe('Upload automatic Bescheid document', () => { it('should show automatic Bescheid button', () => { vorgangList.getListItem(bescheidAutomatik.name).getRoot().click(); waitForSpinnerToDisappear(); diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-list/vorgang-list-ungelesen.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-list/vorgang-list-ungelesen.cy.ts new file mode 100644 index 0000000000000000000000000000000000000000..323803e5693ea60d476d70aff45717143b624cc4 --- /dev/null +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-list/vorgang-list-ungelesen.cy.ts @@ -0,0 +1,127 @@ +import { + PostfachMailE2EComponent, + PostfachMailListItem, +} from 'apps/alfa-e2e/src/components/postfach/postfach-mail.e2e.component'; +import { VorgangSubnavigationE2EComponent } from 'apps/alfa-e2e/src/components/vorgang/vorgang-subnavigation'; +import { + VorgangViewE2EComponent, + VorgangViewsE2EComponent, +} from 'apps/alfa-e2e/src/components/vorgang/vorgang-views.e2e.component'; +import { + PostfachMailItemE2E, + VorgangAttachedItemClientE2E, + VorgangAttachedItemE2E, +} from 'apps/alfa-e2e/src/model/vorgang-attached-item'; +import { VorgangPage } from 'apps/alfa-e2e/src/page-objects/vorgang.po'; +import { exist, haveLength } from 'apps/alfa-e2e/src/support/cypress.util'; +import { + createPostfachNachrichtAttachedItem, + createPostfachNachrichtReplyItem, + initVorgangAttachedItem, +} from 'apps/alfa-e2e/src/support/vorgang-attached-item-util'; +import { VorgangListE2EComponent } from '../../../components/vorgang/vorgang-list.e2e.component'; +import { ClientAttributeNameE2E, ClientAttributesE2E, VorgangE2E } from '../../../model/vorgang'; +import { MainPage, waitForSpinnerToDisappear } from '../../../page-objects/main.po'; +import { dropCollections } from '../../../support/cypress-helper'; +import { initUsermanagerUsers, loginAsSabine } from '../../../support/user-util'; +import { + buildVorgang, + createHasNewPostfachNachrichtClientAttribute, + createHasPostfachNachrichtClientAttribute, + initVorgaenge, + objectIds, +} from '../../../support/vorgang-util'; + +describe('Ungelesene Nachrichten', () => { + const mainPage: MainPage = new MainPage(); + const vorgangList: VorgangListE2EComponent = mainPage.getVorgangList(); + const vorgangPage: VorgangPage = new VorgangPage(); + const subnavigation: VorgangSubnavigationE2EComponent = vorgangPage.getSubnavigation(); + const views: VorgangViewsE2EComponent = mainPage.getViews(); + const ungelesenView: VorgangViewE2EComponent = views.getUngelesen(); + const postfachMailContainer: PostfachMailE2EComponent = vorgangPage.getPostfachMailcontainer(); + + const clientAttributes: ClientAttributesE2E = { + [VorgangAttachedItemClientE2E.OZGCLOUD_NACHRICHTEN_MANAGER]: { + [ClientAttributeNameE2E.HAS_NEW_POSTFACH_NACHRICHT]: + createHasNewPostfachNachrichtClientAttribute(true), + [ClientAttributeNameE2E.HAS_POSTFACH_NACHRICHT]: + createHasPostfachNachrichtClientAttribute(true), + }, + }; + + const vorgangWithReply1: VorgangE2E = { + ...buildVorgang(objectIds[0], 'VorgangWithReply'), + clientAttributes, + }; + + const vorgangWithReply2: VorgangE2E = { + ...buildVorgang(objectIds[1], 'VorgangWithReply 2'), + clientAttributes, + }; + + const postfachMailReply1: PostfachMailItemE2E = createPostfachNachrichtReplyItem(); + const postfachMailReply2: PostfachMailItemE2E = createPostfachNachrichtReplyItem(); + + const postfachNachrichtAttachedItem1: VorgangAttachedItemE2E = { + ...createPostfachNachrichtAttachedItem(objectIds[0], vorgangWithReply1._id.$oid), + item: postfachMailReply1, + }; + const postfachNachrichtAttachedItem2: VorgangAttachedItemE2E = { + ...createPostfachNachrichtAttachedItem(objectIds[1], vorgangWithReply2._id.$oid), + item: postfachMailReply2, + }; + + before(() => { + initVorgaenge([vorgangWithReply1, vorgangWithReply2]); + initVorgangAttachedItem([postfachNachrichtAttachedItem1]); + initVorgangAttachedItem([postfachNachrichtAttachedItem2]); + initUsermanagerUsers(); + + loginAsSabine(); + + waitForSpinnerToDisappear(); + exist(vorgangList.getRoot()); + }); + + after(() => { + dropCollections(); + }); + + describe('Show number of unread messages', () => { + it('should show 2 unread messages in filter', () => { + ungelesenView.getRoot().click(); + waitForSpinnerToDisappear(); + + haveLength(vorgangList.getItems(), 2); + }); + + it('should show 1 unread message after viewing first message', () => { + vorgangList.getListItem(vorgangWithReply1.name).getRoot().click(); + waitForSpinnerToDisappear(); + + const postfachMailItem: PostfachMailListItem = postfachMailContainer.getListItem('Subject'); + postfachMailItem.getRoot().click(); + waitForSpinnerToDisappear(); + + subnavigation.back(); + subnavigation.back(); + + haveLength(vorgangList.getItems(), 1); + }); + + it('should show 0 unread messages after viewing second message', () => { + vorgangList.getListItem(vorgangWithReply2.name).getRoot().click(); + waitForSpinnerToDisappear(); + + const postfachMailItem: PostfachMailListItem = postfachMailContainer.getListItem('Subject'); + postfachMailItem.getRoot().click(); + waitForSpinnerToDisappear(); + + subnavigation.back(); + subnavigation.back(); + + haveLength(vorgangList.getItems(), 0); + }); + }); +}); diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-zusammenarbeit/vorgang-zusammenarbeit-anfragen.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-zusammenarbeit/vorgang-zusammenarbeit-anfragen.cy.ts new file mode 100644 index 0000000000000000000000000000000000000000..e02c7bed2c4080d6b8fa7d227f826722c67699ce --- /dev/null +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/vorgang-zusammenarbeit/vorgang-zusammenarbeit-anfragen.cy.ts @@ -0,0 +1,123 @@ +import { registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; +import localeDeExtra from '@angular/common/locales/extra/de'; +import { VorgangZusammenarbeitE2EComponent } from 'apps/alfa-e2e/src/components/vorgang/vorgang-zusammenarbeit.e2e.component'; +import { VorgangE2E, VorgangStatusE2E } from 'apps/alfa-e2e/src/model/vorgang'; +import 'cypress-real-events/support'; +import { VorgangListE2EComponent } from '../../../components/vorgang/vorgang-list.e2e.component'; +import { MainPage, waitForSpinnerToDisappear } from '../../../page-objects/main.po'; +import { VorgangPage } from '../../../page-objects/vorgang.po'; +import { dropCollections } from '../../../support/cypress-helper'; +import { exist, notExist } from '../../../support/cypress.util'; +import { initUsermanagerUsers, loginAsSabine } from '../../../support/user-util'; +import { buildVorgang, initVorgaenge, objectIds } from '../../../support/vorgang-util'; + +registerLocaleData(localeDe, 'de', localeDeExtra); + +describe('Vorgang Zusammenarbeit anfragen', () => { + const mainPage: MainPage = new MainPage(); + const vorgangList: VorgangListE2EComponent = mainPage.getVorgangList(); + + const vorgangPage: VorgangPage = new VorgangPage(); + const zusammenarbeitContainer: VorgangZusammenarbeitE2EComponent = + vorgangPage.getZusammenarbeitContainer(); + + const zusammenarbeitVorgang: VorgangE2E = { + ...buildVorgang(objectIds[0], 'Zusammenarbeit Vorgang'), + status: VorgangStatusE2E.IN_BEARBEITUNG, + }; + + const titleText: string = 'Dies ist ein Test-Titel !"§$%&'; + const messageText: string = + 'Sehr geehrter Tester\n\n Dies ist ein !"§$%& Test\n zum Testen der Nachricht.\n\n\n\nhier sollte eine \nScrollbar\nangezeigt\nwerden!\n\nMfG!'; + + before(() => { + initVorgaenge([zusammenarbeitVorgang]); + initUsermanagerUsers(); + + loginAsSabine(); + + waitForSpinnerToDisappear(); + exist(vorgangList.getRoot()); + }); + + after(() => { + dropCollections(); + }); + + describe('create new Anfrage', () => { + it('should show button for Zusammenarbeit', () => { + vorgangList.getListItem(zusammenarbeitVorgang.name).getRoot().click(); + waitForSpinnerToDisappear(); + + exist(zusammenarbeitContainer.getAnfrageButton()); + }); + + it('should show input elements on Anfrage click', () => { + zusammenarbeitContainer.createAnfrage(); + + exist(zusammenarbeitContainer.getZustaendigeStelleButton()); + exist(zusammenarbeitContainer.getStelleTitel()); + exist(zusammenarbeitContainer.getMessageText()); + exist(zusammenarbeitContainer.getSendButton()); + exist(zusammenarbeitContainer.getCancelButton()); + }); + + it('should close elements on Cancel click', () => { + zusammenarbeitContainer.cancelAnfrage(); + + notExist(zusammenarbeitContainer.getZustaendigeStelleButton()); + notExist(zusammenarbeitContainer.getStelleTitel()); + notExist(zusammenarbeitContainer.getMessageText()); + notExist(zusammenarbeitContainer.getSendButton()); + notExist(zusammenarbeitContainer.getCancelButton()); + exist(zusammenarbeitContainer.getAnfrageButton()); + }); + + it('should open new search label for Zustaendige Stelle', () => { + zusammenarbeitContainer.createAnfrage(); + //button click + //Layer wird angezeigt + }); + + it('should close layer on Cancel click', () => { + //click Abbrechen + }); + + it('should show no search on entering 1 element', () => { + //1 Zeichen in Suche eingeben + //keine Vorschau + }); + + it('should show results on entering 2 elements', () => { + //2 Zeichen in Suche eingeben + //Vorschau kontrollieren + }); + + it('should delete search term on clicking X', () => { + //X in Suche klicken + //Suche ist leer + //keine Vorschau + }); + + it('should copy and paste Zustaendige Stelle after selection', () => { + //click Suchergebnis + //Adresse und Name wird übernommen + //Layer ist geschlossen + }); + + it('should be able to enter title and message, and show scrollbar on long text', () => { + zusammenarbeitContainer.enterTitel(titleText); + zusammenarbeitContainer.enterMessage(messageText); + + zusammenarbeitContainer.messageScrollbarIsPresent(); + }); + + it('should show title and message read-only and remove buttons after sending', () => { + //Button klicken + //Titel und Datum werden angezeigt + //Nachricht wird angezeigt + //Buttons werden ausgeblendet + }); + }); +}); diff --git a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/wiedervorlage/wiedervorlage.erledigen.cy.ts b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/wiedervorlage/wiedervorlage.erledigen.cy.ts index b20cea9b1d2b31d36db3535b7526a8032f1b88ed..c9ea833a1b6086cabc8286b7821531d0b8aac9bb 100644 --- a/alfa-client/apps/alfa-e2e/src/e2e/main-tests/wiedervorlage/wiedervorlage.erledigen.cy.ts +++ b/alfa-client/apps/alfa-e2e/src/e2e/main-tests/wiedervorlage/wiedervorlage.erledigen.cy.ts @@ -158,6 +158,8 @@ describe('Wiedervorlage erledigen/wiedereroeffnen', () => { waitforSpinnerToAppear(); waitForSpinnerToDisappear(); + wait(1000, 'Wait for async BE to complete'); + containClass(wiedervorlageContainer.getStatusDot(), 'erledigt'); contains( snackBar.getMessage(), @@ -199,6 +201,8 @@ describe('Wiedervorlage erledigen/wiedereroeffnen', () => { waitforSpinnerToAppear(); waitForSpinnerToDisappear(); + wait(1000, 'Wait for async BE to complete'); + notContainClass(wiedervorlageContainer.getStatusDot(), 'erledigt'); contains( snackBar.getMessage(), @@ -229,6 +233,7 @@ describe('Wiedervorlage erledigen/wiedereroeffnen', () => { const locatorIconDefault: string = 'wiedervorlage-icon-default'; it('back to vorgang list', () => { + wait(1000, 'Wait for async BE to complete'); vorgangPage.getSubnavigation().getBackButton().click(); waitForSpinnerToDisappear(); }); @@ -249,6 +254,8 @@ describe('Wiedervorlage erledigen/wiedereroeffnen', () => { subnavigation.erledigen(); waitForSpinnerToDisappear(); + wait(1000, 'Wait for async BE to complete'); + subnavigation.navigateBack(); waitForSpinnerToDisappear(); }); diff --git a/alfa-client/apps/alfa-e2e/src/fixtures/argocd/by-main-dev.yaml b/alfa-client/apps/alfa-e2e/src/fixtures/argocd/by-main-dev.yaml index c73587a0b07ff1fb36e484323bd8f5ea31a19300..ed32236cd2d0a9b3c211c4c54b7a726e212cf500 100644 --- a/alfa-client/apps/alfa-e2e/src/fixtures/argocd/by-main-dev.yaml +++ b/alfa-client/apps/alfa-e2e/src/fixtures/argocd/by-main-dev.yaml @@ -9,9 +9,14 @@ project: alfa: env: overrideSpringProfiles: "oc,e2e,dev" + customList: + ozgcloud_feature_bescheid-wizard: "true" ingress: use_staging_cert: true ozgcloud: + user_assistance: + documentation: + url: /assets/benutzerleitfaden/benutzerleitfaden.pdf vorgang: bescheid: - formEngineName: FormSolutions @@ -20,11 +25,12 @@ alfa: vorgang_manager: env: overrideSpringProfiles: "oc,e2e,dev" - ozgcloud_bescheid_smart_documents_url: http://smocker:8080/smartdocuments - ozgcloud_bescheid_smart_documents_basic_auth_username: MGM - ozgcloud_bescheid_smart_documents_basic_auth_password: MGM - ozgcloud_bescheid_smart_documents_template_group: OzgCloudTest - ozgcloud_bescheid_smart_documents_template: Halteverbot + customList: + ozgcloud_bescheid_smart_documents_url: http://smocker:8080/smartdocuments + ozgcloud_bescheid_smart_documents_basic_auth_username: MGM + ozgcloud_bescheid_smart_documents_basic_auth_password: MGM + ozgcloud_bescheid_smart_documents_template_group: OzgCloudTest + ozgcloud_bescheid_smart_documents_template: Halteverbot elasticsearch: enabled: true replicaCount: 1 diff --git a/alfa-client/apps/alfa-e2e/src/fixtures/user-main/user_zelda.json b/alfa-client/apps/alfa-e2e/src/fixtures/user-main/user_zelda.json new file mode 100644 index 0000000000000000000000000000000000000000..a283f68b0934c9f7b991b4ca08ba36caefa20853 --- /dev/null +++ b/alfa-client/apps/alfa-e2e/src/fixtures/user-main/user_zelda.json @@ -0,0 +1,12 @@ +{ + "name": "zelda", + "password": "Y9nk43yrQ_zzIPpfFU-I", + "firstName": "Zelda", + "lastName": "Zusammen", + "fullName": "Zelda Zusammen", + "email": "zelda.z@ozg-sh.de", + "initials": "ZZ", + "dataTestId": "Zelda_Zusammen", + "clientRoles": ["VERWALTUNG_USER"], + "groups": ["E2E Tests"] +} diff --git a/alfa-client/apps/alfa-e2e/src/fixtures/usermanager/usermanager_user_zelda.json b/alfa-client/apps/alfa-e2e/src/fixtures/usermanager/usermanager_user_zelda.json new file mode 100644 index 0000000000000000000000000000000000000000..f41f879cb6fdddb220a07c880ebc5fba946a7da0 --- /dev/null +++ b/alfa-client/apps/alfa-e2e/src/fixtures/usermanager/usermanager_user_zelda.json @@ -0,0 +1,18 @@ +{ + "_id": { + "$oid": "63284e55c39b316b2ad02e2z" + }, + "createdAt": { + "$date": "2024-08-14T13:11:56.489Z" + }, + "deleted": false, + "keycloakUserId": "2ccf0c13-da74-4516-ae3d-f46d30e8ec0c", + "firstName": "Zelda", + "fullName": "Zelda Zusammen", + "lastName": "Zusammen", + "email": "zelda-z@ozg-sh.de", + "lastSyncTimestamp": 1663585874687, + "organisationsEinheitIds": ["9797773", "9093371"], + "roles": ["VERWALTUNG_USER"], + "username": "zelda" +} diff --git a/alfa-client/apps/alfa-e2e/src/fixtures/vorgang/vorgang.json b/alfa-client/apps/alfa-e2e/src/fixtures/vorgang/vorgang.json index 7db3791c3468b1f263a22f255637f3153de69eee..fd4d23ab75d2d554467e4631abb1848acefd52f7 100644 --- a/alfa-client/apps/alfa-e2e/src/fixtures/vorgang/vorgang.json +++ b/alfa-client/apps/alfa-e2e/src/fixtures/vorgang/vorgang.json @@ -12,6 +12,7 @@ "status": "NEU", "inCreation": false, "header": { + "collaborationLevel": 0, "serviceKonto": { "type": "OSI", "postfachAddresses": [ diff --git a/alfa-client/apps/alfa-e2e/src/model/vorgang.ts b/alfa-client/apps/alfa-e2e/src/model/vorgang.ts index aca577c4cea54f5d30ca05ca61956be6cb82099b..7fdbcfd62b93935aef7f35cb4a4f7fc1858c4422 100644 --- a/alfa-client/apps/alfa-e2e/src/model/vorgang.ts +++ b/alfa-client/apps/alfa-e2e/src/model/vorgang.ts @@ -67,6 +67,7 @@ export class VorgangE2E { export class VorgangHeaderE2E { serviceKonto: ServiceKontoE2E; + collaborationLevel: number; } export class ServiceKontoE2E { diff --git a/alfa-client/apps/alfa-e2e/src/page-objects/vorgang.po.ts b/alfa-client/apps/alfa-e2e/src/page-objects/vorgang.po.ts index 835cc89685361d2d76198e0c331c3a81107fbce8..8b0ef02c9f9fde49c3fa1f498e8db784f02865aa 100644 --- a/alfa-client/apps/alfa-e2e/src/page-objects/vorgang.po.ts +++ b/alfa-client/apps/alfa-e2e/src/page-objects/vorgang.po.ts @@ -36,6 +36,7 @@ import { VorgangFormularDatenE2EComponent } from '../components/vorgang/vorgang- import { VorgangForwardingE2EComponent } from '../components/vorgang/vorgang-forward.e2e.component'; import { VorgangMoreMenuE2EComponent } from '../components/vorgang/vorgang-more-menu.e2e.components'; import { VorgangSubnavigationE2EComponent } from '../components/vorgang/vorgang-subnavigation'; +import { VorgangZusammenarbeitE2EComponent } from '../components/vorgang/vorgang-zusammenarbeit.e2e.component'; import { WiedervorlagenInVorgangE2EComponent } from '../components/wiedervorlage/wiedervorlagen-in-vorgang.e2e.component'; export class VorgangPage { @@ -64,6 +65,8 @@ export class VorgangPage { private readonly postfachMailContainer: PostfachMailE2EComponent = new PostfachMailE2EComponent(); private readonly antragstellerContainer: AntragstellerE2EComponent = new AntragstellerE2EComponent(); + private readonly zusammenArbeitContainer: VorgangZusammenarbeitE2EComponent = + new VorgangZusammenarbeitE2EComponent(); private readonly fixedDialog: FixedDialogE2EComponent = new FixedDialogE2EComponent(); private readonly postfachMailFormular: PostfachMailFormularE2EComponent = @@ -132,6 +135,10 @@ export class VorgangPage { return this.antragstellerContainer; } + public getZusammenarbeitContainer(): VorgangZusammenarbeitE2EComponent { + return this.zusammenArbeitContainer; + } + public getProgressBar() { return cy.getTestElement(this.locatorProgressBar); } diff --git a/alfa-client/apps/alfa-e2e/src/support/cypress-tasks.ts b/alfa-client/apps/alfa-e2e/src/support/cypress-tasks.ts index a82b4f736601daf7f1b3164cbb45818a609e29de..827b7b3d31d21f7c991796b5256302ae28354466 100644 --- a/alfa-client/apps/alfa-e2e/src/support/cypress-tasks.ts +++ b/alfa-client/apps/alfa-e2e/src/support/cypress-tasks.ts @@ -67,6 +67,13 @@ module.exports = (on: any, config: any) => { }, }); + on('after:spec', (spec: Cypress.Spec, results: CypressCommandLine.RunResult) => { + if (results && results.video && results.stats.failures === 0) { + console.log(`Delete recorded video because spec passed: ${results.video}`); + fs.unlinkSync(results.video); + } + }); + // Workaround für Angular 13 und Cypress mit Webpack 4, // Siehe https://github.com/cypress-io/cypress/issues/19066#issuecomment-1012055705 // Entfernen, sobald Cypress Webpack 5 nutzt - https://github.com/cypress-io/cypress/issues/19555 diff --git a/alfa-client/apps/alfa-e2e/src/support/user-util.ts b/alfa-client/apps/alfa-e2e/src/support/user-util.ts index cf437c63163898a88410f7bbb47143774a312967..a89ac2641cc41863bbce0534b308f3c6cc24ad85 100644 --- a/alfa-client/apps/alfa-e2e/src/support/user-util.ts +++ b/alfa-client/apps/alfa-e2e/src/support/user-util.ts @@ -27,11 +27,13 @@ import { initUsermanagerData, login } from './cypress-helper'; const sabineFixture: UserE2E = require('../fixtures/user-main/user_sabine.json'); const dorotheaFixture: UserE2E = require('../fixtures/user-main/user_dorothea.json'); +const zeldaFixture: UserE2E = require('../fixtures/user-main/user_zelda.json'); const userManagerSabineFixture: UsermanagerUserE2E = require('../fixtures/usermanager/usermanager_user_sabine.json'); const userManagerPeterFixture: UsermanagerUserE2E = require('../fixtures/usermanager/usermanager_user_peter.json'); const userManagerEmilFixture: UsermanagerUserE2E = require('../fixtures/usermanager/usermanager_user_emil.json'); const userManagerDorotheaFixture: UsermanagerUserE2E = require('../fixtures/usermanager/usermanager_user_dorothea.json'); +const userManagerZeldaFixture: UsermanagerUserE2E = require('../fixtures/usermanager/usermanager_user_zelda.json'); export function initUsermanagerUsers() { initUsermanagerData([ @@ -39,6 +41,7 @@ export function initUsermanagerUsers() { getUserManagerUserPeter(), getUserManagerUserEmil(), getUserManagerUserDorothea(), + //getUserManagerUserZelda(), ]); } @@ -50,6 +53,10 @@ export function getUserDorothea(): UserE2E { return dorotheaFixture; } +export function getUserZelda(): UserE2E { + return zeldaFixture; +} + export function getUserManagerUserSabine(): UsermanagerUserE2E { return userManagerSabineFixture; } @@ -66,6 +73,10 @@ export function getUserManagerUserDorothea(): UsermanagerUserE2E { return userManagerDorotheaFixture; } +export function getUserManagerUserZelda(): UsermanagerUserE2E { + return userManagerZeldaFixture; +} + export function getUserSabineId(): string { return getUserManagerUserSabine()._id.$oid; } @@ -77,6 +88,7 @@ enum DatabaseUser { PETER = 'user-main/user_peter.json', RICHARD = 'user-main/user_richard.json', SABINE = 'user-main/user_sabine.json', + ZELDA = 'user-main/user_zelda.json', ZONK = 'user-main/user_zonk.json', } @@ -96,6 +108,10 @@ export function loginAsRichard(): void { login(DatabaseUser.RICHARD); } +export function loginAsZelda(): void { + login(DatabaseUser.ZELDA); +} + export function loginAsSabine(): void { login(DatabaseUser.SABINE); } diff --git a/alfa-client/apps/alfa/Caddyfile b/alfa-client/apps/alfa/Caddyfile new file mode 100644 index 0000000000000000000000000000000000000000..440e32b476cf040c1471b9646a946ee8a2f8757a --- /dev/null +++ b/alfa-client/apps/alfa/Caddyfile @@ -0,0 +1,6 @@ +:8080 { + file_server + root * /usr/share/caddy + + try_files {path} /index.html +} \ No newline at end of file diff --git a/alfa-client/apps/alfa/Dockerfile b/alfa-client/apps/alfa/Dockerfile index d69c70876f7ebb414614b0d47d7e5faeb2a44f6d..8ccc0f564746885f35f3286406323cb53fca81b2 100644 --- a/alfa-client/apps/alfa/Dockerfile +++ b/alfa-client/apps/alfa/Dockerfile @@ -1,24 +1,20 @@ -# Benutzt das vorher zu bauende Docker image "nx-build-base:x.y.z" -# Siehe ../Dockerfile.nx-build-base -FROM docker.ozg-sh.de/nx-build-base:2.0.0 AS builder +FROM caddy:2.6.4-alpine -ARG NODE_ENV -ARG CONFIGURATION +RUN adduser --system --ingroup root caddy -# Turn off Nx Daemon -ENV CI=true +WORKDIR /usr/share/caddy -WORKDIR /app/builder -COPY . . +COPY apps/alfa/Caddyfile /etc/caddy/Caddyfile -RUN echo "Building configuration: $CONFIGURATION..." +COPY dist/apps/alfa /usr/share/caddy -RUN npx nx build alfa --outputHashing=all --configuration ${CONFIGURATION:-development} \ - && ./node_modules/.bin/gzipper compress ./dist --verbose --exclude jpg,jpeg,png,ico,woff,woff2 +RUN chgrp -R 0 /usr/bin/caddy /etc/caddy /config/caddy /usr/share/caddy && \ + chmod -R g=u /usr/bin/caddy /etc/caddy /config/caddy /usr/share/caddy -FROM nginx:stable-alpine +USER caddy -WORKDIR /usr/share/nginx/html +EXPOSE 8080 8081 -COPY --from=builder /app/builder/dist/apps/alfa ./ -COPY --from=builder /app/builder/apps/alfa/nginx.conf /etc/nginx/nginx.conf +ENTRYPOINT ["/usr/bin/caddy"] + +CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] \ No newline at end of file diff --git a/alfa-client/apps/alfa/project.json b/alfa-client/apps/alfa/project.json index 539fb173beab9fa6efb50227f3efc49c7cc7bf37..932f9ed857394eebe857e588ed2cd2e7abea2ffb 100644 --- a/alfa-client/apps/alfa/project.json +++ b/alfa-client/apps/alfa/project.json @@ -122,6 +122,22 @@ "outputs": [ "{workspaceRoot}/coverage/apps/alfa" ] + }, + "container": { + "executor": "@nx-tools/nx-container:build", + "options": { + "engine": "docker", + "push": false, + "metadata": { + "images": [ + "docker.ozg-sh.de/alfa-client" + ], + "load": true, + "tags": [ + "build-latest" + ] + } + } } } } \ No newline at end of file diff --git a/alfa-client/apps/demo/src/app/app.component.html b/alfa-client/apps/demo/src/app/app.component.html index 81bc4dff284d5e739981af85b8d4e1cc0c4b3844..7dbdd36d8134a9a38fdd859b1371c5a0362820df 100644 --- a/alfa-client/apps/demo/src/app/app.component.html +++ b/alfa-client/apps/demo/src/app/app.component.html @@ -14,6 +14,16 @@ <nav>NAV</nav> </div> <main class="flex-auto bg-background-50 p-6"> + <div class="my-5"> + <ods-instant-search + headerText="In der OZG-Cloud" + placeholder="zuständige Stelle suchen" + [control]="instantSearchFormControl" + [searchResults]="getInstantSearchResults()" + (searchResultSelected)="selectSearchResult($event)" + (searchQueryChanged)="onSearchQueryChanged($event)" + ></ods-instant-search> + </div> <div class="w-96"> <ods-attachment-wrapper> <ods-attachment @@ -21,6 +31,7 @@ description="234 kB" fileType="pdf" isLoading="true" + loadingCaption="Mein_Bescheid.pdf wird heruntergeladen..." > </ods-attachment> <ods-attachment caption="Mein_Bescheid.xml" description="234 kB" fileType="xml"> @@ -94,7 +105,7 @@ value="abgelehnt" variant="bescheid_abgelehnt" > - <ods-close-icon class="fill-abgelehnt" /> + <ods-close-icon class="fill-abgelehnt" size="large" /> </ods-radio-button-card> </div> </form> @@ -209,14 +220,6 @@ <p text class="text-center">Bescheiddokument<br />hochladen</p></ods-file-upload-button > </div> - - <div class="mt-4"> - <ods-file-upload-button class="w-72" [isLoading]="false" id="upload129"> - <ods-bescheid-upload-icon /> - <ods-spinner-icon spinner size="medium" /> - <div text class="text-center">Anhang hochladen</div></ods-file-upload-button - > - </div> <div class="mt-4"> <ods-file-upload-button class="w-72" [isLoading]="true" id="upload130"> <ods-attachment-icon icon /> diff --git a/alfa-client/apps/demo/src/app/app.component.ts b/alfa-client/apps/demo/src/app/app.component.ts index 5d64b15a623f2330d29201455ec298907bcd94fa..fbd58a87093ed57b19c83b1e1c78151e75ca1b66 100644 --- a/alfa-client/apps/demo/src/app/app.component.ts +++ b/alfa-client/apps/demo/src/app/app.component.ts @@ -15,6 +15,8 @@ import { ErrorMessageComponent, FileIconComponent, FileUploadButtonComponent, + InstantSearchComponent, + OfficeIconComponent, RadioButtonCardComponent, SaveIconComponent, SendIconComponent, @@ -24,6 +26,12 @@ import { TextareaComponent, } from '@ods/system'; +import { EMPTY_STRING } from '@alfa-client/tech-shared'; +import { Resource } from '@ngxp/rest'; +import { + InstantSearchQuery, + InstantSearchResult, +} from 'libs/design-system/src/lib/instant-search/instant-search/instant-search.model'; import { BescheidDialogExampleComponent } from './components/bescheid-dialog/bescheid-dialog.component'; import { BescheidPaperComponent } from './components/bescheid-paper/bescheid-paper.component'; import { BescheidStepperComponent } from './components/bescheid-stepper/bescheid-stepper.component'; @@ -46,6 +54,8 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com BescheidPaperComponent, RadioButtonCardComponent, ReactiveFormsModule, + InstantSearchComponent, + OfficeIconComponent, SaveIconComponent, SendIconComponent, StampIconComponent, @@ -64,14 +74,45 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com templateUrl: './app.component.html', }) export class AppComponent { - title = 'demo'; - darkMode = signal<boolean>(JSON.parse(window.localStorage.getItem('darkMode') ?? 'false')); @HostBinding('class.dark') get mode() { return this.darkMode(); } + title = 'demo'; + + instantSearchItems: InstantSearchResult<Resource>[] = [ + { + title: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht', + description: 'Fabrikstraße 8-10, 24103 Kiel', + }, + { + title: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck', + description: 'Rathausmarkt 7, Hersbruck', + }, + { + title: 'Amt für Digitalisierung, Breitband und Vermessung Stuttgart', + description: 'Rathausmarkt 7, Stuttgart', + }, + { + title: 'Amt für Digitalisierung, Breitband und Vermessung Ulm', + description: 'Rathausmarkt 7, Ulm', + }, + ]; + instantSearchFormControl = new FormControl(EMPTY_STRING); + + getInstantSearchResults() { + if (this.instantSearchFormControl.value.length < 2) return []; + return this.instantSearchItems.filter((item) => + item.title.toLowerCase().includes(this.instantSearchFormControl.value.toLowerCase()), + ); + } + + selectSearchResult(result: InstantSearchResult<Resource>) { + console.log(result); + } + exampleForm = new FormGroup({ exampleName: new FormControl('bewilligt'), }); @@ -87,4 +128,8 @@ export class AppComponent { window.localStorage.setItem('darkMode', JSON.stringify(this.darkMode())); }); } + + public onSearchQueryChanged(searchQuery: InstantSearchQuery) { + console.info('Search query: %o', searchQuery); + } } diff --git a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.html b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.html index 2baa2a383b01b1e32ac4295240fc0a0890a47f52..303f8720da7e44c2cdcc5618f70a64d366e4c64e 100644 --- a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.html +++ b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.html @@ -13,11 +13,11 @@ /> </label> <div - *ngIf="errorMessages.length > 0" + *ngIf="invalidParams.length > 0" [attr.data-test-id]="'text-field-errors-' + label | convertForDataTest" > - <span class="mb-3 italic text-red-500" *ngFor="let errorMessage of errorMessages">{{ - errorMessage + <span class="mb-3 italic text-red-500" *ngFor="let invalidParam of invalidParams">{{ + getErrorMessage(invalidParam) }}</span> </div> </div> diff --git a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.spec.ts b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.spec.ts index 44f27c6b31b79144a38f750312ba6404f2e00647..9096bedd3ab98897a3adc9474b291c7703144572 100644 --- a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.spec.ts +++ b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.spec.ts @@ -1,37 +1,22 @@ -import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; -import { getElementFromFixture, notExistsAsHtmlElement } from '@alfa-client/test-utils'; +import { ConvertForDataTestPipe, InvalidParam } from '@alfa-client/tech-shared'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl, NgControl, ReactiveFormsModule, ValidationErrors } from '@angular/forms'; -import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { ReactiveFormsModule } from '@angular/forms'; +import { createInvalidParam, createProblemDetail } from 'libs/tech-shared/test/error'; import { TextFieldComponent } from './text-field.component'; +import * as TechValidationUtil from 'libs/tech-shared/src/lib/validation/tech.validation.util'; + describe('TextFieldComponent', () => { let component: TextFieldComponent; let fixture: ComponentFixture<TextFieldComponent>; const label = 'custom'; - const spanSelector = getDataTestIdOf('text-field-span-' + label); - const inputSelector = getDataTestIdOf('text-field-input-' + label); - const errorsSelector = getDataTestIdOf('text-field-errors-' + label); - - const formControl = new FormControl(''); beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TextFieldComponent, ConvertForDataTestPipe], imports: [ReactiveFormsModule], - }) - .overrideComponent(TextFieldComponent, { - add: { - providers: [ - { - provide: NgControl, - useValue: formControl, - }, - ], - }, - }) - .compileComponents(); + }).compileComponents(); fixture = TestBed.createComponent(TextFieldComponent); component = fixture.componentInstance; @@ -43,62 +28,39 @@ describe('TextFieldComponent', () => { expect(component).toBeTruthy(); }); - it('should use label', () => { - const labelElement = getElementFromFixture(fixture, spanSelector); - expect(labelElement.textContent).toBe(label); - }); - - it('should use form control', () => { - const fieldText = 'text'; - component.writeValue(fieldText); - - fixture.detectChanges(); - - const inputElement = getElementFromFixture(fixture, inputSelector); - expect(inputElement.value).toBe(fieldText); - }); + describe('getErrorMessage', () => { + it('should call getMessageForInvalidParam()', () => { + const getMessageForInvalidParam: jest.SpyInstance<string, [string, InvalidParam]> = + jest.spyOn(TechValidationUtil, 'getMessageForInvalidParam'); + const invalidParam: InvalidParam = createInvalidParam(); - describe('invalid indication', () => { - it('should show as red if invalid', () => { - formControl.setErrors({ someErrorCode: 'Invalid' }); + component.getErrorMessage(invalidParam); - fixture.detectChanges(); - - const labelElement = getElementFromFixture(fixture, spanSelector); - expect([...labelElement.classList]).toEqual(['text-red-500', 'font-bold']); + expect(getMessageForInvalidParam).toHaveBeenCalledWith(label, invalidParam); }); + }); - it('should not show as red if valid', () => { - formControl.setErrors(null); + describe('show error messages', () => { + it('should not call getErrorMessage() if no error', () => { + component.getErrorMessage = jest.fn(); + component.fieldControl.setErrors({}); fixture.detectChanges(); - const labelElement = getElementFromFixture(fixture, spanSelector); - expect([...labelElement.classList]).toEqual([]); + expect(component.getErrorMessage).not.toHaveBeenCalled(); }); - }); - describe('error messages', () => { - it('should not show empty error message container', () => { - fixture.detectChanges(); - - notExistsAsHtmlElement(fixture, errorsSelector); - }); + it('should call getErrorMessage() if error', () => { + component.getErrorMessage = jest.fn(); - it('should show error messages', () => { - const errors: ValidationErrors = { - firstMessage: 'first', - secondMessage: 'second', - }; - formControl.setErrors(errors); + component.fieldControl.setErrors({ + ...createProblemDetail(), + invalidParams: [{ ...createInvalidParam(), name: 'settingBody.absender.name' }], + }); fixture.detectChanges(); - const errorsElement: HTMLElement = getElementFromFixture(fixture, errorsSelector); - const spansTexts = Array.from(errorsElement.querySelectorAll('span')).map( - (span) => span.textContent, - ); - expect(spansTexts).toEqual(Object.values(errors)); + expect(component.getErrorMessage).toHaveBeenCalled(); }); }); }); diff --git a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.ts b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.ts index bca9352718c8cbdb0e8f0434095d574215da1577..00037394425175ac3fcca9fe68b1ba286e3c6315 100644 --- a/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.ts +++ b/alfa-client/libs/admin-settings/src/lib/shared/text-field/text-field.component.ts @@ -1,4 +1,4 @@ -import { isNotNil } from '@alfa-client/tech-shared'; +import { getMessageForInvalidParam, InvalidParam } from '@alfa-client/tech-shared'; import { Component, Input } from '@angular/core'; import { FormControlEditorAbstractComponent } from '@ods/component'; @@ -10,7 +10,7 @@ export class TextFieldComponent extends FormControlEditorAbstractComponent { @Input() label: string; - get errorMessages(): string[] { - return isNotNil(this.control?.errors) ? Object.values(this.control.errors) : []; + public getErrorMessage(invalidParam: InvalidParam): string { + return getMessageForInvalidParam(this.label, invalidParam); } } diff --git a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts index 00d79760d53d50091d8cfc8284cc392ff0169138..d879837d0cde6d8081e5c80f469126888ad48946 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts @@ -156,7 +156,7 @@ describe('BescheidService', () => { }); it('should call facade', () => { - service.createBescheid(vorgangWithEingang).pipe(first()).subscribe(); + service.createBescheid(vorgangWithEingang).subscribe(); expect(facade.createBescheidDraft).toHaveBeenCalledWith(vorgangWithEingang, { order: CommandOrder.CREATE_BESCHEID, @@ -165,7 +165,7 @@ describe('BescheidService', () => { }); it('should set resource by uri', () => { - service.createBescheid(vorgangWithEingang).pipe(first()).subscribe(); + service.createBescheid(vorgangWithEingang).subscribe(); expect(service.bescheidResourceService.loadByResourceUri).toHaveBeenCalledWith( getUrl(command, CommandLinkRel.EFFECTED_RESOURCE), @@ -430,15 +430,10 @@ describe('BescheidService', () => { it('should clear create bescheid document in progress', (done) => { service.createBescheidDocumentInProgress$.next(createCommandStateResource()); - service - .updateBescheid(bescheid) - .pipe(first()) - .subscribe(() => { - expect(service.createBescheidDocumentInProgress$.value).toEqual( - createEmptyStateResource(), - ); - done(); - }); + service.updateBescheid(bescheid).subscribe(() => { + expect(service.createBescheidDocumentInProgress$.value).toEqual(createEmptyStateResource()); + done(); + }); }); it('should clear upload bescheid document in progress', (done) => { @@ -1079,7 +1074,7 @@ describe('BescheidService', () => { }); it('should get resource', () => { - service.bescheidVerwerfen().pipe(first()).subscribe(); + service.bescheidVerwerfen().subscribe(); expect(service.getResource).toHaveBeenCalled(); }); @@ -1261,19 +1256,19 @@ describe('BescheidService', () => { }); it('should get items', () => { - service.getLastBescheid().pipe(first()).subscribe(); + service.getLastBescheid().subscribe(); expect(getItemsSpy).toHaveBeenCalled(); }); it('should filter by sent status', () => { - service.getLastBescheid().pipe(first()).subscribe(); + service.getLastBescheid().subscribe(); expect(service.filterBySentStatus).toHaveBeenCalledWith(bescheide); }); it('should sort by beschieden am', () => { - service.getLastBescheid().pipe(first()).subscribe(); + service.getLastBescheid().subscribe(); expect(sortByGermanDateStrSpy).toHaveBeenCalledWith(bescheide, expect.any(Function)); }); @@ -1302,13 +1297,13 @@ describe('BescheidService', () => { }); it('should get items', () => { - service.existBescheid().pipe(first()).subscribe(); + service.existBescheid().subscribe(); expect(getItemsSpy).toHaveBeenCalled(); }); it('should filter by sent status', () => { - service.existBescheid().pipe(first()).subscribe(); + service.existBescheid().subscribe(); expect(service.filterBySentStatus).toHaveBeenCalledWith(bescheide); }); diff --git a/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html b/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html index 704a6ddd28f903eee9a9fb887c8ac40d4d56683d..61ae1d119505cb9e60d173dce420dd872cfa784e 100644 --- a/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html +++ b/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html @@ -9,7 +9,6 @@ <ods-close-icon *ngIf="!bescheid.bewilligt" data-test-id="abgelehnt-icon" - size="small" class="fill-abgelehnt" /> diff --git a/alfa-client/libs/collaboration-shared/.eslintrc.json b/alfa-client/libs/collaboration-shared/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..adbe7ae2dfabd4a42804f00846baec80877f4c5c --- /dev/null +++ b/alfa-client/libs/collaboration-shared/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/alfa-client/libs/collaboration-shared/README.md b/alfa-client/libs/collaboration-shared/README.md new file mode 100644 index 0000000000000000000000000000000000000000..84ff0ba5e53fa8755bbc483ca6989a4dd97ba944 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/README.md @@ -0,0 +1,7 @@ +# collaboration-shared + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test collaboration-shared` to execute the unit tests. diff --git a/alfa-client/libs/collaboration-shared/jest.config.ts b/alfa-client/libs/collaboration-shared/jest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..b295000beb0ccff8274950b36d2b0c08ef7eb11c --- /dev/null +++ b/alfa-client/libs/collaboration-shared/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +export default { + displayName: 'collaboration-shared', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], + globals: {}, + coverageDirectory: '../../coverage/libs/collaboration-shared', + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], + transform: { + '^.+.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '<rootDir>/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], +}; diff --git a/alfa-client/libs/collaboration-shared/project.json b/alfa-client/libs/collaboration-shared/project.json new file mode 100644 index 0000000000000000000000000000000000000000..f262eeba5a931ef0e8f50e915af8be561bbf8cd6 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/project.json @@ -0,0 +1,22 @@ +{ + "name": "collaboration-shared", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/collaboration-shared/src", + "prefix": "alfa", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "tsConfig": "libs/collaboration-shared/tsconfig.spec.json", + "jestConfig": "libs/collaboration-shared/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/alfa-client/libs/collaboration-shared/src/index.ts b/alfa-client/libs/collaboration-shared/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd761bf34a9d3d1e63337bb36e92e56977ef0b1e --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/collaboration-shared.module'; +export * from './lib/collaboration.linkrel'; +export * from './lib/collaboration.model'; +export * from './lib/organisations-einheit.linkrel'; +export * from './lib/organisations-einheit.model'; +export * from './lib/organisations-einheit.service'; diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration-list-resource.service.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration-list-resource.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa6f47d5d6292485b6d84de0040fc1913c692569 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration-list-resource.service.ts @@ -0,0 +1,34 @@ +import { + ListResourceServiceConfig, + ResourceListService, + ResourceRepository, +} from '@alfa-client/tech-shared'; +import { + VorgangResource, + VorgangService, + VorgangWithEingangLinkRel, +} from '@alfa-client/vorgang-shared'; +import { CollaborationListLinkRel } from './collaboration.linkrel'; +import { CollaborationListResource, CollaborationResource } from './collaboration.model'; + +export class CollaborationListResourceService extends ResourceListService< + VorgangResource, + CollaborationListResource, + CollaborationResource +> {} + +export function createCollaborationListResourceService( + repository: ResourceRepository, + vorgangService: VorgangService, +) { + return new ResourceListService(buildConfig(vorgangService), repository); +} + +function buildConfig(vorgangService: VorgangService): ListResourceServiceConfig<VorgangResource> { + return { + baseResource: vorgangService.getVorgangWithEingang(), + listLinkRel: VorgangWithEingangLinkRel.COLLABORATIONS, + listResourceListLinkRel: CollaborationListLinkRel.COLLABORATION_LIST, + createLinkRel: CollaborationListLinkRel.CREATE_COLLABORATION_REQUEST, + }; +} diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.spec.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a3a485289dcdf8dc388b32f75c4415104122f46 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; +import { CollaborationSharedModule } from './collaboration-shared.module'; + +describe('CollaborationSharedModule', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollaborationSharedModule], + }).compileComponents(); + }); + + it('should create', () => { + expect(CollaborationSharedModule).toBeDefined(); + }); +}); diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4a95fe57bb26a6a1fe9cd8de98e6c2422b0033b --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration-shared.module.ts @@ -0,0 +1,33 @@ +import { ResourceRepository } from '@alfa-client/tech-shared'; +import { VorgangService } from '@alfa-client/vorgang-shared'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { + CollaborationListResourceService, + createCollaborationListResourceService, +} from './collaboration-list-resource.service'; +import { CollaborationService } from './collaboration.service'; +import { + OrganisationsEinheitResourceSearchService, + createOrganisationsEinheitResourceSearchService, +} from './organisations-einheit-resource-search.service'; +import { OrganisationsEinheitService } from './organisations-einheit.service'; + +@NgModule({ + imports: [CommonModule], + providers: [ + CollaborationService, + OrganisationsEinheitService, + { + provide: CollaborationListResourceService, + useFactory: createCollaborationListResourceService, + deps: [ResourceRepository, VorgangService], + }, + { + provide: OrganisationsEinheitResourceSearchService, + useFactory: createOrganisationsEinheitResourceSearchService, + deps: [ResourceRepository, VorgangService], + }, + ], +}) +export class CollaborationSharedModule {} diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration.linkrel.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration.linkrel.ts new file mode 100644 index 0000000000000000000000000000000000000000..967a0a0a1990749d02d9153c1e2d1a4d88869936 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration.linkrel.ts @@ -0,0 +1,4 @@ +export enum CollaborationListLinkRel { + COLLABORATION_LIST = 'collaborationList', + CREATE_COLLABORATION_REQUEST = 'createCollaborationRequest', +} diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration.model.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e5a678748f2ac3076ff3a7cd94f60d5bf69eae0 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration.model.ts @@ -0,0 +1,11 @@ +import { ListItemResource, ListResource } from '@alfa-client/tech-shared'; +import { Resource, ResourceUri } from '@ngxp/rest'; + +export interface Collaboration { + titel: string; + anfrage: string; + zustaendigeStelle: ResourceUri; +} + +export interface CollaborationResource extends Collaboration, Resource, ListItemResource {} +export interface CollaborationListResource extends ListResource {} diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.spec.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5cdf3d7a38229b500188f238c7ed137ac9a475b --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.spec.ts @@ -0,0 +1,119 @@ +import { CommandOrder, CommandResource, CommandService } from '@alfa-client/command-shared'; +import { StateResource, createStateResource } from '@alfa-client/tech-shared'; +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import { createCommandResource } from 'libs/command-shared/test/command'; +import { singleColdCompleted } from 'libs/tech-shared/test/marbles'; +import { Observable, of } from 'rxjs'; +import { + createCollaboration, + createCollaborationListResource, +} from '../../../collaboration-shared/test/collaboration'; +import { CollaborationListResourceService } from './collaboration-list-resource.service'; +import { CollaborationListLinkRel } from './collaboration.linkrel'; +import { Collaboration, CollaborationListResource } from './collaboration.model'; +import { CollaborationService } from './collaboration.service'; + +jest.mock('./collaboration-list-resource.service'); + +describe('CollaborationService', () => { + let service: CollaborationService; + + let listService: Mock<CollaborationListResourceService>; + let commandService: Mock<CommandService>; + + const collaborationListResource: CollaborationListResource = createCollaborationListResource(); + const collaborationStateListResource: StateResource<CollaborationListResource> = + createStateResource(collaborationListResource); + + beforeEach(() => { + listService = mock(CollaborationListResourceService); + commandService = mock(CommandService); + + service = new CollaborationService(useFromMock(listService), useFromMock(commandService)); + }); + + describe('get list', () => { + beforeEach(() => { + listService.getList.mockReturnValue(of(collaborationStateListResource)); + }); + + it('should call service', () => { + service.getList(); + + expect(listService.getList).toHaveBeenCalled(); + }); + + it('should return value', () => { + const list$: Observable<StateResource<CollaborationListResource>> = service.getList(); + + expect(list$).toBeObservable(singleColdCompleted(collaborationStateListResource)); + }); + }); + + describe('is request form visible', () => { + it('should return value', (done) => { + service.showRequestForm$.next(false); + + service.isRequestFormVisible().subscribe((isVisible: boolean) => { + expect(isVisible).toBeTruthy(); + done(); + }); + + service.showRequestForm(); + }); + }); + + describe('show anfrage formular', () => { + it('should set "showRequestForm" to true', () => { + service.showRequestForm$.next(false); + + service.showRequestForm(); + + expect(service.showRequestForm$.value).toBeTruthy(); + }); + }); + + describe('hide anfrage formular', () => { + it('should set "showRequestForm" to false', () => { + service.showRequestForm$.next(true); + + service.hideRequestForm(); + + expect(service.showRequestForm$.value).toBeFalsy(); + }); + }); + + describe('create', () => { + const collaborationListResource: CollaborationListResource = createCollaborationListResource(); + const commandStateResource: StateResource<CommandResource> = + createStateResource(createCommandResource()); + const collaboration: Collaboration = createCollaboration(); + + beforeEach(() => { + commandService.createCommandByProps.mockReturnValue(of(commandStateResource)); + }); + + it('should call command service', () => { + service.create(collaborationListResource, collaboration); + + expect(commandService.createCommandByProps).toHaveBeenCalledWith({ + linkRel: CollaborationListLinkRel.CREATE_COLLABORATION_REQUEST, + resource: collaborationListResource, + command: { + order: CommandOrder.CREATE_COLLABORATION_REQUEST, + body: collaboration, + }, + snackBarMessage: 'Die Zuarbeit wurde angefragt.', + }); + }); + + it('should return value', () => { + const created$: Observable<StateResource<CommandResource>> = service.create( + collaborationListResource, + collaboration, + ); + + expect(created$).toBeObservable(singleColdCompleted(commandStateResource)); + }); + }); +}); diff --git a/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.ts b/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f210a1133df3293f5bfa9da2bdd97fe033af1f6c --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/collaboration.service.ts @@ -0,0 +1,48 @@ +import { CommandOrder, CommandResource, CommandService } from '@alfa-client/command-shared'; +import { StateResource } from '@alfa-client/tech-shared'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { CollaborationListResourceService } from './collaboration-list-resource.service'; +import { CollaborationListLinkRel } from './collaboration.linkrel'; +import { Collaboration, CollaborationListResource } from './collaboration.model'; + +@Injectable() +export class CollaborationService { + showRequestForm$: BehaviorSubject<boolean> = new BehaviorSubject(false); + + constructor( + private listService: CollaborationListResourceService, + private commandService: CommandService, + ) {} + + public getList(): Observable<StateResource<CollaborationListResource>> { + return this.listService.getList(); + } + + public isRequestFormVisible(): Observable<boolean> { + return this.showRequestForm$.asObservable(); + } + + public showRequestForm(): void { + this.showRequestForm$.next(true); + } + + public hideRequestForm(): void { + this.showRequestForm$.next(false); + } + + public create( + listResource: CollaborationListResource, + collaborationRequest: Collaboration, + ): Observable<StateResource<CommandResource>> { + return this.commandService.createCommandByProps({ + linkRel: CollaborationListLinkRel.CREATE_COLLABORATION_REQUEST, + resource: listResource, + command: { + order: CommandOrder.CREATE_COLLABORATION_REQUEST, + body: collaborationRequest, + }, + snackBarMessage: 'Die Zuarbeit wurde angefragt.', + }); + } +} diff --git a/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service.ts b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..23f726f9ed323a5c596e660190d1168e0aea41e5 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service.ts @@ -0,0 +1,35 @@ +import { + ResourceRepository, + ResourceSearchService, + SearchResourceServiceConfig, + mapToResource, +} from '@alfa-client/tech-shared'; +import { + VorgangResource, + VorgangService, + VorgangWithEingangLinkRel, +} from '@alfa-client/vorgang-shared'; +import { + OrganisationsEinheitListResource, + OrganisationsEinheitResource, +} from './organisations-einheit.model'; + +export class OrganisationsEinheitResourceSearchService extends ResourceSearchService< + VorgangResource, + OrganisationsEinheitListResource, + OrganisationsEinheitResource +> {} + +export function createOrganisationsEinheitResourceSearchService( + repository: ResourceRepository, + vorgangService: VorgangService, +) { + return new ResourceSearchService(buildConfig(vorgangService), repository); +} + +function buildConfig(vorgangService: VorgangService): SearchResourceServiceConfig<VorgangResource> { + return { + baseResource: vorgangService.getVorgangWithEingang().pipe(mapToResource<VorgangResource>()), + searchLinkRel: VorgangWithEingangLinkRel.SEARCH_ORGANISATIONS_EINHEIT, + }; +} diff --git a/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.linkrel.ts b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.linkrel.ts new file mode 100644 index 0000000000000000000000000000000000000000..775f60796051e119cb42f35c368799fe23250981 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.linkrel.ts @@ -0,0 +1,3 @@ +export enum OrganisationsEinheitListLinkRel { + ORGANISATIONS_EINHEIT_HEADER_LIST = 'organisationsEinheitHeaderList', +} diff --git a/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.model.ts b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..aafc23fbbce95dae57f511413b8e1c0dedd936ad --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.model.ts @@ -0,0 +1,20 @@ +import { ListItemResource, ListResource } from '@alfa-client/tech-shared'; +import { Resource } from '@ngxp/rest'; + +export interface OrganisationsEinheit { + name: string; + anschrift: Anschrift; +} + +export interface Anschrift { + strasse: string; + hausnummer: string; + plz: string; + ort: string; +} + +export interface OrganisationsEinheitResource + extends OrganisationsEinheit, + Resource, + ListItemResource {} +export interface OrganisationsEinheitListResource extends ListResource {} diff --git a/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.spec.ts b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f264006658fe044ac57ad9a2926427046ba6f029 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.spec.ts @@ -0,0 +1,110 @@ +import { StateResource, createStateResource } from '@alfa-client/tech-shared'; +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import faker from '@faker-js/faker'; +import { Observable, of } from 'rxjs'; +import { singleColdCompleted } from '../../../tech-shared/test/marbles'; +import { + createOrganisationsEinheitListResource, + createOrganisationsEinheitResource, +} from '../../test/organisations-einheit'; +import { OrganisationsEinheitResourceSearchService } from './organisations-einheit-resource-search.service'; +import { + OrganisationsEinheitListResource, + OrganisationsEinheitResource, +} from './organisations-einheit.model'; +import { OrganisationsEinheitService } from './organisations-einheit.service'; + +jest.mock('./organisations-einheit-resource-search.service'); + +describe('OrganisationsEinheitService', () => { + let service: OrganisationsEinheitService; + + let searchService: Mock<OrganisationsEinheitResourceSearchService>; + + const listResource: OrganisationsEinheitListResource = createOrganisationsEinheitListResource(); + const listStateResource: StateResource<OrganisationsEinheitListResource> = + createStateResource(listResource); + + beforeEach(() => { + searchService = mock(OrganisationsEinheitResourceSearchService); + + service = new OrganisationsEinheitService(useFromMock(searchService)); + }); + + describe('get search result list', () => { + it('should call search service', () => { + service.getSearchResultList(); + + expect(searchService.getResultList).toHaveBeenCalled(); + }); + + it('should return result', (done) => { + searchService.getResultList.mockReturnValue(of(listStateResource)); + + service + .getSearchResultList() + .subscribe((result: StateResource<OrganisationsEinheitListResource>) => { + expect(result).toBe(listStateResource); + done(); + }); + }); + }); + + describe('search', () => { + const searchBy: string = faker.random.word(); + + it('should call search service with search string', () => { + service.search(searchBy); + + expect(searchService.search).toHaveBeenCalledWith(searchBy); + }); + }); + + describe('clear search result', () => { + it('should call search service', () => { + service.clearSearchResult(); + + expect(searchService.clearResultList).toHaveBeenCalledWith(); + }); + }); + + describe('get selected result', () => { + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + beforeEach(() => { + searchService.getSelectedResult.mockReturnValue(of(organisationsEinheitResource)); + }); + + it('should call service', () => { + service.getSelectedResult(); + + expect(searchService.getSelectedResult).toHaveBeenCalled(); + }); + + it('should return result', () => { + const selectedResult$: Observable<OrganisationsEinheitResource> = service.getSelectedResult(); + + expect(selectedResult$).toBeObservable(singleColdCompleted(organisationsEinheitResource)); + }); + }); + + describe('select search result', () => { + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + it('should call service', () => { + service.selectSearchResult(organisationsEinheitResource); + + expect(searchService.selectResult).toHaveBeenCalledWith(organisationsEinheitResource); + }); + }); + + describe('clear selected result', () => { + it('should call service', () => { + service.clearSelectedResult(); + + expect(searchService.clearSelectedResult).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.ts b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..495c115446391b8d126feef91bc6b02ad9c24134 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/lib/organisations-einheit.service.ts @@ -0,0 +1,37 @@ +import { StateResource } from '@alfa-client/tech-shared'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { OrganisationsEinheitResourceSearchService } from './organisations-einheit-resource-search.service'; +import { + OrganisationsEinheitListResource, + OrganisationsEinheitResource, +} from './organisations-einheit.model'; + +@Injectable() +export class OrganisationsEinheitService { + constructor(private readonly searchService: OrganisationsEinheitResourceSearchService) {} + + public getSearchResultList(): Observable<StateResource<OrganisationsEinheitListResource>> { + return this.searchService.getResultList(); + } + + public search(searchBy: string): void { + this.searchService.search(searchBy); + } + + public clearSearchResult(): void { + this.searchService.clearResultList(); + } + + public getSelectedResult(): Observable<OrganisationsEinheitResource> { + return this.searchService.getSelectedResult(); + } + + public selectSearchResult(organisationsEinheitResource: OrganisationsEinheitResource): void { + this.searchService.selectResult(organisationsEinheitResource); + } + + public clearSelectedResult(): void { + this.searchService.clearSelectedResult(); + } +} diff --git a/alfa-client/libs/collaboration-shared/src/test-setup.ts b/alfa-client/libs/collaboration-shared/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b07c0bac34c40aa6afeef02c18c8db08f79de48 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/src/test-setup.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom'; +import 'jest-preset-angular/setup-jest'; + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +getTestBed().resetTestEnvironment(); +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { + teardown: { destroyAfterEach: false }, + errorOnUnknownProperties: true, + errorOnUnknownElements: true, +}); diff --git a/alfa-client/libs/collaboration-shared/test/collaboration.ts b/alfa-client/libs/collaboration-shared/test/collaboration.ts new file mode 100644 index 0000000000000000000000000000000000000000..fef47c56b2280e8cc85ae90773b90ba9d910d868 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/test/collaboration.ts @@ -0,0 +1,38 @@ +import faker from '@faker-js/faker'; +import { times } from 'lodash-es'; +import { LinkRelationName } from '../../tech-shared/src'; +import { toResource } from '../../tech-shared/test/resource'; +import { CollaborationListLinkRel, OrganisationsEinheitResource } from '../src'; +import { + Collaboration, + CollaborationListResource, + CollaborationResource, +} from '../src/lib/collaboration.model'; + +export function createCollaboration(): Collaboration { + return { + titel: faker.random.words(2), + anfrage: faker.random.words(10), + zustaendigeStelle: faker.internet.url(), + }; +} + +export function createCollaborationResource( + linkRelations: LinkRelationName[] = [], +): CollaborationResource { + return toResource(createCollaboration(), linkRelations); +} + +export function createCollaborationResources( + linkRelations: LinkRelationName[] = [], +): OrganisationsEinheitResource[] { + return times(10, () => toResource(createCollaborationResource(), [...linkRelations])); +} + +export function createCollaborationListResource( + linkRelations: LinkRelationName[] = [], +): CollaborationListResource { + return toResource({}, [...linkRelations], { + [CollaborationListLinkRel.COLLABORATION_LIST]: createCollaborationResources(), + }); +} diff --git a/alfa-client/libs/collaboration-shared/test/organisations-einheit.ts b/alfa-client/libs/collaboration-shared/test/organisations-einheit.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c0f749b773824c841749f7cc5f7851bf7a44134 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/test/organisations-einheit.ts @@ -0,0 +1,49 @@ +import { times } from 'lodash-es'; +import { toResource } from '../../tech-shared/test/resource'; +import { + Anschrift, + OrganisationsEinheit, + OrganisationsEinheitListLinkRel, + OrganisationsEinheitListResource, + OrganisationsEinheitResource, +} from '../src'; + +import { faker } from '@faker-js/faker'; + +export function createAnschrift(): Anschrift { + return { + hausnummer: faker.random.word(), + ort: faker.random.word(), + plz: faker.random.word(), + strasse: faker.random.words(2), + }; +} + +export function createOrganisationsEinheit(): OrganisationsEinheit { + return { + name: faker.random.word(), + anschrift: createAnschrift(), + }; +} + +export function createOrganisationsEinheitResource( + linkRel: string[] = [], +): OrganisationsEinheitResource { + return toResource(createOrganisationsEinheit(), linkRel); +} + +export function createOrganisationsEinheitResources( + linkRelations: string[] = [], +): OrganisationsEinheitResource[] { + return times(10, () => toResource(createOrganisationsEinheitResource(), [...linkRelations])); +} + +export function createOrganisationsEinheitListResource( + resources?: OrganisationsEinheitResource[], + linkRelations: string[] = [], +): OrganisationsEinheitListResource { + return toResource({}, [...linkRelations], { + [OrganisationsEinheitListLinkRel.ORGANISATIONS_EINHEIT_HEADER_LIST]: + resources ? resources : createOrganisationsEinheitResources(), + }); +} diff --git a/alfa-client/libs/collaboration-shared/tsconfig.json b/alfa-client/libs/collaboration-shared/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7cc6baf2f58ed5ccfba098131996f579979e9f18 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "target": "es2022" + } +} diff --git a/alfa-client/libs/collaboration-shared/tsconfig.lib.json b/alfa-client/libs/collaboration-shared/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..dcc35a71ee8a26bc9394b9cf96b02996be26ec16 --- /dev/null +++ b/alfa-client/libs/collaboration-shared/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/test-setup.ts", "src/**/*.spec.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/alfa-client/libs/collaboration-shared/tsconfig.spec.json b/alfa-client/libs/collaboration-shared/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..c3cb17f784fa2f7b9a4b0e7c9b03663500f4ee3d --- /dev/null +++ b/alfa-client/libs/collaboration-shared/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "target": "ES2022", + "useDefineForClassFields": false + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts", "jest.config.ts"] +} diff --git a/alfa-client/libs/collaboration/.eslintrc.json b/alfa-client/libs/collaboration/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..243c51741f65cc7afb3a7d85531c24afdcab5e56 --- /dev/null +++ b/alfa-client/libs/collaboration/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "alfa", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "alfa", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/alfa-client/libs/collaboration/README.md b/alfa-client/libs/collaboration/README.md new file mode 100644 index 0000000000000000000000000000000000000000..53577986259ad6d8403d1446c6c1426d24e6d06d --- /dev/null +++ b/alfa-client/libs/collaboration/README.md @@ -0,0 +1,7 @@ +# collaboration + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test collaboration` to execute the unit tests. diff --git a/alfa-client/libs/collaboration/jest.config.ts b/alfa-client/libs/collaboration/jest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdccd307388de8b7fb2256a71ec07808ec5b05de --- /dev/null +++ b/alfa-client/libs/collaboration/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'collaboration', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/collaboration', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '<rootDir>/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/alfa-client/libs/collaboration/project.json b/alfa-client/libs/collaboration/project.json new file mode 100644 index 0000000000000000000000000000000000000000..39d9434a1f4c96550e4d71423eed803a6c397993 --- /dev/null +++ b/alfa-client/libs/collaboration/project.json @@ -0,0 +1,21 @@ +{ + "name": "collaboration", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/collaboration/src", + "prefix": "alfa", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/collaboration"], + "options": { + "tsConfig": "libs//collaboration/tsconfig.spec.json", + "jestConfig": "libs/collaboration/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/alfa-client/libs/collaboration/src/index.ts b/alfa-client/libs/collaboration/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe7fae5e1dd38a384cd93d2ad8425d93ac37ae03 --- /dev/null +++ b/alfa-client/libs/collaboration/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component'; +export * from './lib/collaboration.module'; diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.html b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.html new file mode 100644 index 0000000000000000000000000000000000000000..d0d5db82cd5b543c578693ef03c4907a134077f8 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.html @@ -0,0 +1,8 @@ +<alfa-collaboration-in-vorgang + data-test-id="collaboration-in-vorgang" + [collaborationStateListResource]="collaborationStateListResource$ | async" + [isRequestFormVisible]="isRequestFormVisible$ | async" + [organisationsEinheit]="selectedOrganisationsEinheit$ | async" + (hideRequestForm)="hideRequestForm()" + (showRequestForm)="showRequestForm()" +></alfa-collaboration-in-vorgang> diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5e6e9e22dc4c22f174f59115b1bb954d1addaf3 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.spec.ts @@ -0,0 +1,166 @@ +import { + CollaborationListResource, + OrganisationsEinheitResource, +} from '@alfa-client/collaboration-shared'; +import { StateResource, createStateResource } from '@alfa-client/tech-shared'; +import { Mock, dispatchEventFromFixture, getMockComponent, mock } from '@alfa-client/test-utils'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CollaborationService } from 'libs/collaboration-shared/src/lib/collaboration.service'; +import { OrganisationsEinheitResourceSearchService } from 'libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service'; +import { createCollaborationListResource } from 'libs/collaboration-shared/test/collaboration'; +import { createOrganisationsEinheitResource } from 'libs/collaboration-shared/test/organisations-einheit'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { CollaborationInVorgangContainerComponent } from './collaboration-in-vorgang-container.component'; +import { CollaborationInVorgangComponent } from './collaboration-in-vorgang/collaboration-in-vorgang.component'; + +jest.mock('libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service'); + +describe('CollaborationInVorgangContainerComponent', () => { + let component: CollaborationInVorgangContainerComponent; + let fixture: ComponentFixture<CollaborationInVorgangContainerComponent>; + + const collaborationInVorgangComp: string = getDataTestIdOf('collaboration-in-vorgang'); + + const service: Mock<CollaborationService> = { + ...mock(CollaborationService), + isRequestFormVisible: jest.fn().mockReturnValue(of(false)), + }; + + const organisationsEinheitSearchService: Mock<OrganisationsEinheitResourceSearchService> = mock( + OrganisationsEinheitResourceSearchService, + ); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + CollaborationInVorgangContainerComponent, + MockComponent(CollaborationInVorgangComponent), + ], + providers: [ + { + provide: CollaborationService, + useValue: service, + }, + { + provide: OrganisationsEinheitResourceSearchService, + useValue: organisationsEinheitSearchService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CollaborationInVorgangContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call service to get collaboration state list resource', () => { + component.ngOnInit(); + + expect(service.getList).toHaveBeenCalled(); + }); + + it('should call service to get request form visbility', () => { + component.ngOnInit(); + + expect(service.isRequestFormVisible).toHaveBeenCalled(); + }); + + it('should get selected result', () => { + component.ngOnInit(); + + expect(organisationsEinheitSearchService.getSelectedResult).toHaveBeenCalled(); + }); + }); + + describe('collaboration in vorgang component', () => { + const collaborationListResource: CollaborationListResource = createCollaborationListResource(); + const collaborationStateListResource: StateResource<CollaborationListResource> = + createStateResource(collaborationListResource); + + describe('should be called with', () => { + it('collaboration state list resource', () => { + component.collaborationStateListResource$ = of(collaborationStateListResource); + + fixture.detectChanges(); + + expect(getCollaborationInVorgangComponent().collaborationStateListResource).toBe( + collaborationStateListResource, + ); + }); + + it('is request form visible', () => { + component.isRequestFormVisible$ = of(true); + + fixture.detectChanges(); + + expect(getCollaborationInVorgangComponent().isRequestFormVisible).toBeTruthy(); + }); + + it('organisations einheit', () => { + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + component.selectedOrganisationsEinheit$ = of(organisationsEinheitResource); + + fixture.detectChanges(); + + expect(getCollaborationInVorgangComponent().organisationsEinheit).toBe( + organisationsEinheitResource, + ); + }); + + function getCollaborationInVorgangComponent(): CollaborationInVorgangComponent { + return getMockComponent<CollaborationInVorgangComponent>( + fixture, + CollaborationInVorgangComponent, + ); + } + }); + + it('should call hideRequestForm on output', () => { + component.hideRequestForm = jest.fn(); + + dispatchEventFromFixture(fixture, collaborationInVorgangComp, 'hideRequestForm'); + + expect(component.hideRequestForm).toHaveBeenCalled(); + }); + + it('should call showRequestForm on output', () => { + component.showRequestForm = jest.fn(); + + dispatchEventFromFixture(fixture, collaborationInVorgangComp, 'showRequestForm'); + + expect(component.showRequestForm).toHaveBeenCalled(); + }); + }); + + describe('show request form', () => { + it('should call service', () => { + component.showRequestForm(); + + expect(service.showRequestForm).toHaveBeenCalled(); + }); + }); + + describe('hide request form', () => { + it('should call service', () => { + component.hideRequestForm(); + + expect(service.hideRequestForm).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy', () => { + it('should call service to clear selected result', () => { + component.ngOnDestroy(); + + expect(organisationsEinheitSearchService.clearSelectedResult).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..50c5d04bcffdbd5cd7171a14c5f696839b7447ea --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang-container.component.ts @@ -0,0 +1,40 @@ +import { OrganisationsEinheitResource } from '@alfa-client/collaboration-shared'; +import { StateResource } from '@alfa-client/tech-shared'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CollaborationListResource } from 'libs/collaboration-shared/src/lib/collaboration.model'; +import { CollaborationService } from 'libs/collaboration-shared/src/lib/collaboration.service'; +import { OrganisationsEinheitResourceSearchService } from 'libs/collaboration-shared/src/lib/organisations-einheit-resource-search.service'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'alfa-collaboration-in-vorgang-container', + templateUrl: './collaboration-in-vorgang-container.component.html', +}) +export class CollaborationInVorgangContainerComponent implements OnInit, OnDestroy { + public collaborationStateListResource$: Observable<StateResource<CollaborationListResource>>; + public isRequestFormVisible$: Observable<boolean>; + public selectedOrganisationsEinheit$: Observable<OrganisationsEinheitResource>; + + constructor( + private readonly service: CollaborationService, + private readonly searchService: OrganisationsEinheitResourceSearchService, + ) {} + + ngOnInit(): void { + this.collaborationStateListResource$ = this.service.getList(); + this.isRequestFormVisible$ = this.service.isRequestFormVisible(); + this.selectedOrganisationsEinheit$ = this.searchService.getSelectedResult(); + } + + public showRequestForm(): void { + this.service.showRequestForm(); + } + + public hideRequestForm(): void { + this.service.hideRequestForm(); + } + + ngOnDestroy(): void { + this.searchService.clearSelectedResult(); + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.html b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.html new file mode 100644 index 0000000000000000000000000000000000000000..733601490e198e7d93d37626f597974081b8367a --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.html @@ -0,0 +1,38 @@ +<ng-container + *ngIf=" + collaborationStateListResource.resource + | hasLink: collaborationListLinkRel.CREATE_COLLABORATION_REQUEST + " +> + <ng-template #anfrageErstellenButton> + <ods-button + variant="outline" + text="Anfrage erstellen" + dataTestId="anfrage-erstellen-button" + (clickEmitter)="showRequestForm.emit()" + > + <ods-collaboration-icon icon /> + </ods-button> + </ng-template> + + <ng-container *ngIf="isRequestFormVisible; else anfrageErstellenButton"> + <alfa-collaboration-request-form + data-test-id="collaboration-request-form" + [collaborationListResource]="collaborationStateListResource.resource" + (hide)="hideRequestForm.emit()" + (showResult)="setCollaboration($event)" + ></alfa-collaboration-request-form> + </ng-container> +</ng-container> +<ng-container *ngIf="collaboration"> + <div data-test-id="collaboration-request-result"> + <div class="flex items-center gap-3"> + <ods-office-icon size="large" class="fill-text" /> + <alfa-organisations-einheit + [organisationsEinheitResource]="organisationsEinheit" + ></alfa-organisations-einheit> + </div> + <h4 class="my-6 text-xl font-medium">{{ collaboration.titel }}</h4> + <p class="text-base">{{ collaboration.anfrage }}</p> + </div> +</ng-container> diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b17cd273cc3b503a6accb79219bb149c351041a --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.spec.ts @@ -0,0 +1,189 @@ +import { + Collaboration, + CollaborationListLinkRel, + CollaborationListResource, +} from '@alfa-client/collaboration-shared'; +import { HasLinkPipe, createStateResource } from '@alfa-client/tech-shared'; +import { + EventData, + dispatchEventFromFixture, + existsAsHtmlElement, + getMockComponent, + notExistsAsHtmlElement, + triggerEvent, +} from '@alfa-client/test-utils'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ButtonComponent, CollaborationIconComponent, OfficeIconComponent } from '@ods/system'; +import { + createCollaboration, + createCollaborationListResource, +} from 'libs/collaboration-shared/test/collaboration'; +import { getDataTestIdAttributeOf, getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; +import { CollaborationRequestFormComponent } from '../collaboration-request-form/collaboration-request-form.component'; +import { OrganisationsEinheitComponent } from '../collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component'; +import { CollaborationInVorgangComponent } from './collaboration-in-vorgang.component'; + +describe('CollaborationInVorgangComponent', () => { + let component: CollaborationInVorgangComponent; + let fixture: ComponentFixture<CollaborationInVorgangComponent>; + + const anfrageErstellenButton: string = getDataTestIdAttributeOf('anfrage-erstellen-button'); + const collaborationRequestForm: string = getDataTestIdOf('collaboration-request-form'); + const collaborationRequestResult: string = getDataTestIdOf('collaboration-request-result'); + + const collaborationListResource: CollaborationListResource = createCollaborationListResource(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + CollaborationInVorgangComponent, + HasLinkPipe, + MockComponent(ButtonComponent), + MockComponent(CollaborationRequestFormComponent), + MockComponent(CollaborationIconComponent), + MockComponent(OfficeIconComponent), + MockComponent(OrganisationsEinheitComponent), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CollaborationInVorgangComponent); + component = fixture.componentInstance; + component.collaborationStateListResource = createStateResource(collaborationListResource); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('if create collaboration request link exists', () => { + const collaborationListResourceWithLink: CollaborationListResource = + createCollaborationListResource([CollaborationListLinkRel.CREATE_COLLABORATION_REQUEST]); + beforeEach(() => { + component.collaborationStateListResource = createStateResource( + collaborationListResourceWithLink, + ); + fixture.detectChanges(); + }); + + describe('anfrage erstellen button', () => { + describe('on request form visibility false', () => { + beforeEach(() => { + component.isRequestFormVisible = false; + }); + + it('should be shown', () => { + fixture.detectChanges(); + + existsAsHtmlElement(fixture, anfrageErstellenButton); + }); + + it('should call service on click', () => { + fixture.detectChanges(); + const showRequestFormSpy: jest.SpyInstance = (component.showRequestForm.emit = jest.fn()); + + dispatchEventFromFixture(fixture, anfrageErstellenButton, 'clickEmitter'); + + expect(showRequestFormSpy).toHaveBeenCalled(); + }); + }); + + it('should be hidden if request form visibility is true', () => { + component.isRequestFormVisible = true; + + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, anfrageErstellenButton); + }); + }); + + describe('zustaendige stelle', () => { + describe('on request form visibility true', () => { + beforeEach(() => { + component.isRequestFormVisible = true; + }); + it('should be shown', () => { + fixture.detectChanges(); + + existsAsHtmlElement(fixture, collaborationRequestForm); + }); + + describe('component', () => { + it('should call service on hideFormular output', () => { + fixture.detectChanges(); + const hideRequestFormSpy: jest.SpyInstance = (component.hideRequestForm.emit = + jest.fn()); + + dispatchEventFromFixture(fixture, collaborationRequestForm, 'hide'); + + expect(hideRequestFormSpy).toHaveBeenCalled(); + }); + + it('should call set collaboration on showResult output', () => { + fixture.detectChanges(); + component.setCollaboration = jest.fn(); + const collaboration: Collaboration = createCollaboration(); + + const eventData: EventData<CollaborationInVorgangComponent> = { + fixture, + elementSelector: collaborationRequestForm, + name: 'showResult', + data: collaboration, + }; + triggerEvent(eventData); + + expect(component.setCollaboration).toHaveBeenCalledWith(collaboration); + }); + + it('should be called with', () => { + fixture.detectChanges(); + + const comp: CollaborationRequestFormComponent = + getMockComponent<CollaborationRequestFormComponent>( + fixture, + CollaborationRequestFormComponent, + ); + expect(comp.collaborationListResource).toBe(collaborationListResourceWithLink); + }); + }); + }); + + it('should be hidden if request form visibility is false', () => { + component.isRequestFormVisible = false; + + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, collaborationRequestForm); + }); + }); + }); + + describe('on existing collaboration', () => { + it('should show result', () => { + component.collaboration = createCollaboration(); + + fixture.detectChanges(); + + existsAsHtmlElement(fixture, collaborationRequestResult); + }); + }); + + describe('set collaboration', () => { + const collaboration: Collaboration = createCollaboration(); + + it('should set attribute', () => { + component.setCollaboration(collaboration); + + expect(component.collaboration).toBe(collaboration); + }); + + it('should call emitter', () => { + const hideRequestFormEmitSpy: jest.SpyInstance = (component.hideRequestForm.emit = jest.fn()); + + component.setCollaboration(collaboration); + + expect(hideRequestFormEmitSpy).toBeCalled(); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3216f10156d4e4506b3a028a833700a04b4eb96b --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component.ts @@ -0,0 +1,30 @@ +import { + Collaboration, + CollaborationListLinkRel, + CollaborationListResource, + OrganisationsEinheitResource, +} from '@alfa-client/collaboration-shared'; +import { StateResource } from '@alfa-client/tech-shared'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'alfa-collaboration-in-vorgang', + templateUrl: './collaboration-in-vorgang.component.html', +}) +export class CollaborationInVorgangComponent { + @Input() public collaborationStateListResource: StateResource<CollaborationListResource>; + @Input() public isRequestFormVisible: boolean; + @Input() public organisationsEinheit: OrganisationsEinheitResource; + + @Output() public readonly showRequestForm: EventEmitter<void> = new EventEmitter<void>(); + @Output() public readonly hideRequestForm: EventEmitter<void> = new EventEmitter<void>(); + + public readonly collaborationListLinkRel = CollaborationListLinkRel; + + public collaboration: Collaboration; + + public setCollaboration(collaboration: Collaboration) { + this.collaboration = collaboration; + this.hideRequestForm.emit(); + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.html b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.html new file mode 100644 index 0000000000000000000000000000000000000000..bbb5fd59fc99d36e5f449e35d879bdc5b50bde88 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.html @@ -0,0 +1,34 @@ +<alfa-organisations-einheit-container + [fieldControl]="formService.form.controls.zustaendigeStelle" +></alfa-organisations-einheit-container> + +<form [formGroup]="formService.form" class="mt-4 flex flex-col gap-2"> + <ods-text-editor + label="Titel" + [formControlName]="formServiceClass.FIELD_TITLE" + [isRequired]="true" + ></ods-text-editor> + <ods-textarea-editor + label="Nachricht" + [formControlName]="formServiceClass.FIELD_NACHRICHT" + [isRequired]="true" + ></ods-textarea-editor> +</form> + +<div class="mt-4 flex items-center gap-6"> + <ods-button-with-spinner + text="Zuarbeit anfragen" + dataTestId="collaboration-request-submit-button" + [stateResource]="submitInProgress$ | async" + (clickEmitter)="submit()" + > + </ods-button-with-spinner> + <ods-button + variant="outline" + text="Abbrechen" + dataTestId="collaboration-request-cancel-button" + (clickEmitter)="hide.emit()" + > + <ods-close-icon icon class="fill-primary"></ods-close-icon> + </ods-button> +</div> diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ec28ea4c355c960aeb9be676a5ef47cd6026814 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.spec.ts @@ -0,0 +1,176 @@ +import { CollaborationListResource } from '@alfa-client/collaboration-shared'; +import { CommandLinkRel, CommandResource } from '@alfa-client/command-shared'; +import { StateResource, createStateResource } from '@alfa-client/tech-shared'; +import { + dispatchEventFromFixture, + getMockComponent, + mock, + useFromMock, +} from '@alfa-client/test-utils'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { + ButtonWithSpinnerComponent, + TextEditorComponent, + TextareaEditorComponent, +} from '@ods/component'; +import { ButtonComponent, CloseIconComponent } from '@ods/system'; +import { CollaborationService } from 'libs/collaboration-shared/src/lib/collaboration.service'; +import { createCollaborationListResource } from 'libs/collaboration-shared/test/collaboration'; +import { createCommandResource } from 'libs/command-shared/test/command'; +import { getDataTestIdAttributeOf } from 'libs/tech-shared/test/data-test'; +import { singleColdCompleted } from 'libs/tech-shared/test/marbles'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { CollaborationRequestFormComponent } from './collaboration-request-form.component'; +import { CollaborationRequestFormService } from './collaboration.request.formservice'; +import { OrganisationsEinheitContainerComponent } from './organisations-einheit-container/organisations-einheit-container.component'; + +describe('CollaborationRequestFormComponent', () => { + let component: CollaborationRequestFormComponent; + let fixture: ComponentFixture<CollaborationRequestFormComponent>; + + const cancelButton: string = getDataTestIdAttributeOf('collaboration-request-cancel-button'); + const submitButton: string = getDataTestIdAttributeOf('collaboration-request-submit-button'); + + let formService: CollaborationRequestFormService; + + const stateCommandResource: StateResource<CommandResource> = + createStateResource(createCommandResource()); + + beforeEach(async () => { + formService = new CollaborationRequestFormService( + new FormBuilder(), + useFromMock(mock(CollaborationService)), + ); + TestBed.overrideComponent(CollaborationRequestFormComponent, { + set: { + providers: [ + { + provide: CollaborationRequestFormService, + useValue: formService, + }, + ], + }, + }); + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + declarations: [ + CollaborationRequestFormComponent, + MockComponent(ButtonComponent), + MockComponent(ButtonWithSpinnerComponent), + MockComponent(CloseIconComponent), + MockComponent(TextEditorComponent), + MockComponent(TextareaEditorComponent), + MockComponent(OrganisationsEinheitContainerComponent), + ], + providers: [ + { + provide: CollaborationRequestFormService, + useValue: formService, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(CollaborationRequestFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('cancel button', () => { + it('should emit hideRequestForm', () => { + const emitSpy: jest.SpyInstance = (component.hide.emit = jest.fn()); + + dispatchEventFromFixture(fixture, cancelButton, 'clickEmitter'); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); + + describe('submit button', () => { + describe('component', () => { + it('should be called with state resource', () => { + component.submitInProgress$ = of(stateCommandResource); + + fixture.detectChanges(); + + const submitButtonComp: ButtonWithSpinnerComponent = + getMockComponent<ButtonWithSpinnerComponent>(fixture, ButtonWithSpinnerComponent); + expect(submitButtonComp.stateResource).toBe(stateCommandResource); + }); + }); + + it('should call submit on output', () => { + component.submit = jest.fn(); + + dispatchEventFromFixture(fixture, submitButton, 'clickEmitter'); + + expect(component.submit).toHaveBeenCalled(); + }); + }); + + describe('submit', () => { + it('should call doSubmit', () => { + component.doSubmit = jest.fn(); + + component.submit(); + + expect(component.doSubmit).toHaveBeenCalled(); + }); + + it('should set submitInProgress', () => { + component.doSubmit = jest.fn().mockReturnValue(of(stateCommandResource)); + + component.submit(); + + expect(component.submitInProgress$).toBeObservable(singleColdCompleted(stateCommandResource)); + }); + }); + + describe('do submit', () => { + beforeEach(() => { + formService.submit = jest + .fn() + .mockReturnValue( + of(createStateResource(createCommandResource([CommandLinkRel.EFFECTED_RESOURCE]))), + ); + }); + + it('should call formService', () => { + formService.submit = jest.fn().mockReturnValue(of(stateCommandResource)); + + component.doSubmit().subscribe(); + + expect(formService.submit).toHaveBeenCalled(); + }); + + it('should emit show result', () => { + const showResultSpy: jest.SpyInstance = (component.showResult.emit = jest.fn()); + + component.doSubmit().subscribe(); + + expect(showResultSpy).toHaveBeenCalledWith(formService.form.value); + }); + + it('should return value', () => { + formService.submit = jest.fn().mockReturnValue(of(stateCommandResource)); + + expect(component.doSubmit()).toBeObservable(singleColdCompleted(stateCommandResource)); + }); + }); + + describe('set collaboration list resource', () => { + it('should call set list resource on formService', () => { + formService.setListResource = jest.fn(); + const collaborationListResource: CollaborationListResource = + createCollaborationListResource(); + + component.collaborationListResource = collaborationListResource; + + expect(formService.setListResource).toHaveBeenCalledWith(collaborationListResource); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccaf4fd89ec7f6af5d2afe69d3ff397669cd841c --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component.ts @@ -0,0 +1,42 @@ +import { Collaboration, CollaborationListResource } from '@alfa-client/collaboration-shared'; +import { CommandResource, tapOnCommandSuccessfullyDone } from '@alfa-client/command-shared'; +import { HttpError, StateResource, createEmptyStateResource } from '@alfa-client/tech-shared'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { CollaborationRequestFormService } from './collaboration.request.formservice'; + +@Component({ + selector: 'alfa-collaboration-request-form', + templateUrl: './collaboration-request-form.component.html', + providers: [CollaborationRequestFormService], +}) +export class CollaborationRequestFormComponent { + @Output() public hide: EventEmitter<void> = new EventEmitter<void>(); + @Output() public showResult: EventEmitter<Collaboration> = new EventEmitter<Collaboration>(); + + @Input() public set collaborationListResource( + collaborationListResource: CollaborationListResource, + ) { + this.formService.setListResource(collaborationListResource); + } + + public submitInProgress$: Observable<StateResource<CommandResource | HttpError>> = of( + createEmptyStateResource<CommandResource>(), + ); + + constructor(readonly formService: CollaborationRequestFormService) {} + + public readonly formServiceClass = CollaborationRequestFormService; + + public submit(): void { + this.submitInProgress$ = this.doSubmit(); + } + + doSubmit(): Observable<StateResource<CommandResource>> { + return this.formService + .submit() + .pipe( + tapOnCommandSuccessfullyDone(() => this.showResult.emit(this.formService.getFormValue())), + ); + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9f135fc6a49e51d32f30ee11708d49189343178 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.spec.ts @@ -0,0 +1,67 @@ +import { CollaborationListResource } from '@alfa-client/collaboration-shared'; +import { CommandResource } from '@alfa-client/command-shared'; +import { HttpError, StateResource, createStateResource } from '@alfa-client/tech-shared'; +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import { UntypedFormBuilder } from '@angular/forms'; +import { CollaborationService } from 'libs/collaboration-shared/src/lib/collaboration.service'; +import { createCollaborationListResource } from 'libs/collaboration-shared/test/collaboration'; +import { createCommandResource } from 'libs/command-shared/test/command'; +import { singleColdCompleted } from 'libs/tech-shared/test/marbles'; +import { Observable, of } from 'rxjs'; +import { CollaborationRequestFormService } from './collaboration.request.formservice'; + +describe('CollaborationRequestFormService', () => { + let formService: CollaborationRequestFormService; + + let service: Mock<CollaborationService>; + + const formBuilder: UntypedFormBuilder = new UntypedFormBuilder(); + + const collaborationListResource: CollaborationListResource = createCollaborationListResource(); + + beforeEach(() => { + service = mock(CollaborationService); + + formService = new CollaborationRequestFormService(formBuilder, useFromMock(service)); + }); + + it('should create', () => { + expect(formService).toBeTruthy(); + }); + + describe('do submit', () => { + const stateCommandResource: StateResource<CommandResource> = + createStateResource(createCommandResource()); + + beforeEach(() => { + formService.listResource = collaborationListResource; + service.create.mockReturnValue(of(stateCommandResource)); + }); + + it('should call service', () => { + formService.submit(); + + expect(service.create).toHaveBeenCalledWith( + collaborationListResource, + formService.form.value, + ); + }); + + it('should return stateCommandResource', () => { + const response$: Observable<StateResource<CommandResource | HttpError>> = + formService.submit(); + + expect(response$).toBeObservable(singleColdCompleted(stateCommandResource)); + }); + }); + + describe('set list resource', () => { + it('should set given list resource', () => { + formService.listResource = undefined; + + formService.setListResource(collaborationListResource); + + expect(formService.listResource).toBe(collaborationListResource); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.ts new file mode 100644 index 0000000000000000000000000000000000000000..eedf11fea57b5413757b863d50fee87000986a58 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/collaboration.request.formservice.ts @@ -0,0 +1,48 @@ +import { CollaborationListResource } from '@alfa-client/collaboration-shared'; +import { CommandResource } from '@alfa-client/command-shared'; +import { AbstractFormService, StateResource } from '@alfa-client/tech-shared'; +import { Injectable } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { ResourceUri } from '@ngxp/rest'; +import { CollaborationService } from 'libs/collaboration-shared/src/lib/collaboration.service'; +import { Observable } from 'rxjs'; + +@Injectable() +export class CollaborationRequestFormService extends AbstractFormService<CommandResource> { + public static readonly FIELD_ZUSTAENDIGE_STELLE: string = 'zustaendigeStelle'; + public static readonly FIELD_TITLE: string = 'titel'; + public static readonly FIELD_NACHRICHT: string = 'anfrage'; + + private static readonly PATH_PREFIX: string = 'command.body'; + + listResource: CollaborationListResource; + + constructor( + formBuilder: FormBuilder, + private service: CollaborationService, + ) { + super(formBuilder); + } + + protected initForm(): FormGroup { + return this.formBuilder.group({ + [CollaborationRequestFormService.FIELD_ZUSTAENDIGE_STELLE]: new FormControl<ResourceUri>( + null, + ), + [CollaborationRequestFormService.FIELD_TITLE]: new FormControl<string>(null), + [CollaborationRequestFormService.FIELD_NACHRICHT]: new FormControl<string>(null), + }); + } + + protected doSubmit(): Observable<StateResource<CommandResource>> { + return this.service.create(this.listResource, this.getFormValue()); + } + + protected getPathPrefix(): string { + return CollaborationRequestFormService.PATH_PREFIX; + } + + public setListResource(listResource: CollaborationListResource): void { + this.listResource = listResource; + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.html b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.html new file mode 100644 index 0000000000000000000000000000000000000000..26bf7894a39f4bc7fb5d5d1e07d51dc2cd2976cd --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.html @@ -0,0 +1,23 @@ +<ng-container + *ngIf="organisationsEinheitResource$ | async as organisationsEinheitResource; else searchButton" +> + <div class="flex items-center gap-3"> + <ods-office-icon size="large" class="fill-text" /> + <alfa-organisations-einheit + data-test-id="organisations-einheit-in-collaboration" + [organisationsEinheitResource]="organisationsEinheitResource" + ></alfa-organisations-einheit> + </div> +</ng-container> +<ng-template #searchButton> + <div class="flex items-center gap-3"> + <ods-button + variant="outline" + text="Zuständige Stelle auswählen" + data-test-id="organisations-einheit-search-button" + (clickEmitter)="openSearchDialog()" + > + <ods-search-icon icon /> + </ods-button> + </div> +</ng-template> diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fe951c4b33dbd191a352467286baaa168922529 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.spec.ts @@ -0,0 +1,228 @@ +import { + OrganisationsEinheitResource, + OrganisationsEinheitService, +} from '@alfa-client/collaboration-shared'; +import { + Mock, + dispatchEventFromFixture, + existsAsHtmlElement, + getMockComponent, + mock, +} from '@alfa-client/test-utils'; +import { OzgcloudDialogService } from '@alfa-client/ui'; +import { DialogConfig } from '@angular/cdk/dialog'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { getUrl } from '@ngxp/rest'; +import { ButtonComponent, OfficeIconComponent } from '@ods/system'; +import { createOrganisationsEinheitResource } from 'libs/collaboration-shared/test/organisations-einheit'; +import { SearchIconComponent } from 'libs/design-system/src/lib/icons/search-icon/search-icon.component'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { SearchOrganisationsEinheitContainerComponent } from '../../../search-organisations-einheit-container/search-organisations-einheit-container.component'; +import { OrganisationsEinheitContainerComponent } from './organisations-einheit-container.component'; +import { OrganisationsEinheitComponent } from './organisations-einheit/organisations-einheit.component'; + +describe('OrganisationsEinheitContainerComponent', () => { + let component: OrganisationsEinheitContainerComponent; + let fixture: ComponentFixture<OrganisationsEinheitContainerComponent>; + + const searchOrganisationsEinheitButton: string = getDataTestIdOf( + 'organisations-einheit-search-button', + ); + const organisationsEinheitComp: string = getDataTestIdOf( + 'organisations-einheit-in-collaboration', + ); + + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + let dialogService: Mock<OzgcloudDialogService>; + let service: Mock<OrganisationsEinheitService>; + + beforeEach(async () => { + dialogService = mock(OzgcloudDialogService); + service = { + ...mock(OrganisationsEinheitService), + getSelectedResult: jest.fn().mockReturnValue(of(createOrganisationsEinheitResource())), + }; + TestBed.overrideComponent(OrganisationsEinheitContainerComponent, { + set: { + providers: [ + { + provide: OrganisationsEinheitService, + useValue: service, + }, + ], + }, + }); + await TestBed.configureTestingModule({ + declarations: [ + OrganisationsEinheitContainerComponent, + MockComponent(SearchIconComponent), + MockComponent(OfficeIconComponent), + MockComponent(ButtonComponent), + MockComponent(OrganisationsEinheitComponent), + ], + providers: [ + { + provide: OzgcloudDialogService, + useValue: dialogService, + }, + { + provide: OrganisationsEinheitService, + useValue: service, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OrganisationsEinheitContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInt', () => { + beforeEach(() => { + component.getSelectedResult = jest.fn().mockReturnValue(of(organisationsEinheitResource)); + }); + + it('should call getSelectedResult', () => { + component.getSelectedResult = jest.fn(); + + component.ngOnInit(); + + expect(component.getSelectedResult).toHaveBeenCalled(); + }); + + it('should set organisationsEinheitResource', (done) => { + component.ngOnInit(); + + component.organisationsEinheitResource$.subscribe( + (organisationsEinheitResource: OrganisationsEinheitResource) => { + expect(organisationsEinheitResource).toBe(organisationsEinheitResource); + done(); + }, + ); + }); + }); + + describe('get selected result', () => { + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + beforeEach(() => { + service.getSelectedResult.mockReturnValue(of(organisationsEinheitResource)); + }); + + it('should call service', () => { + component.getSelectedResult().subscribe(); + + expect(service.getSelectedResult).toHaveBeenCalled(); + }); + + it('should call handleResult', () => { + component.handleResult = jest.fn(); + + component.getSelectedResult().subscribe(); + + expect(component.handleResult).toHaveBeenCalledWith(organisationsEinheitResource); + }); + + it('should return value', (done) => { + component.handleResult = jest.fn(); + + component.getSelectedResult().subscribe((result) => { + expect(result).toBe(organisationsEinheitResource); + done(); + }); + }); + }); + + describe('handle result', () => { + beforeEach(() => { + component.fieldControl = new FormControl(); + }); + + it('should patch fieldControl with resource uri', () => { + const fieldControlPatchSpy: jest.SpyInstance = (component.fieldControl.patchValue = + jest.fn()); + + component.handleResult(organisationsEinheitResource); + + expect(fieldControlPatchSpy).toHaveBeenCalledWith(getUrl(organisationsEinheitResource)); + }); + + it('should not patch fieldControl if organisationsEinheit resource is null', () => { + const fieldControlPatchSpy: jest.SpyInstance = (component.fieldControl.patchValue = + jest.fn()); + + component.handleResult(null); + + expect(fieldControlPatchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('search zustaendige stelle button', () => { + beforeEach(() => { + component.organisationsEinheitResource$ = of(undefined); + }); + + it('should be visible on missing organisationsEinheit', () => { + fixture.detectChanges(); + + existsAsHtmlElement(fixture, searchOrganisationsEinheitButton); + }); + it('should call openSearchDialog', () => { + component.openSearchDialog = jest.fn(); + + fixture.detectChanges(); + dispatchEventFromFixture(fixture, searchOrganisationsEinheitButton, 'clickEmitter'); + + expect(component.openSearchDialog).toHaveBeenCalled(); + }); + }); + + describe('organisationsEinheit component', () => { + beforeEach(() => { + component.organisationsEinheitResource$ = of(organisationsEinheitResource); + }); + + it('should be visible on existing organisationsEinheit', () => { + fixture.detectChanges(); + + existsAsHtmlElement(fixture, organisationsEinheitComp); + }); + + it('should be called with resource', () => { + fixture.detectChanges(); + + const comp: OrganisationsEinheitComponent = getMockComponent<OrganisationsEinheitComponent>( + fixture, + OrganisationsEinheitComponent, + ); + expect(comp.organisationsEinheitResource).toBe(organisationsEinheitResource); + }); + }); + + describe('open search dialog', () => { + it('should call dialog service', () => { + const DIALOG_CONFIG: DialogConfig = { + backdropClass: ['backdrop-blur-1', 'bg-greybackdrop'], + }; + + component.openSearchDialog(); + + expect(dialogService.openInCallingComponentContext).toHaveBeenCalledWith( + SearchOrganisationsEinheitContainerComponent, + component.viewContainerRef, + null, + DIALOG_CONFIG, + ); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bddc100f8d72ccd8560386dd144574e532a58e6 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component.ts @@ -0,0 +1,62 @@ +import { + OrganisationsEinheitResource, + OrganisationsEinheitService, +} from '@alfa-client/collaboration-shared'; +import { isNotNull } from '@alfa-client/tech-shared'; +import { OzgcloudDialogService } from '@alfa-client/ui'; +import { DialogConfig } from '@angular/cdk/dialog'; +import { Component, Input, OnInit, ViewContainerRef } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { ResourceUri, getUrl } from '@ngxp/rest'; +import { Observable, tap } from 'rxjs'; +import { SearchOrganisationsEinheitContainerComponent } from '../../../search-organisations-einheit-container/search-organisations-einheit-container.component'; + +const DIALOG_CONFIG: DialogConfig = { + backdropClass: ['backdrop-blur-1', 'bg-greybackdrop'], +}; + +@Component({ + selector: 'alfa-organisations-einheit-container', + templateUrl: './organisations-einheit-container.component.html', + providers: [OrganisationsEinheitService], +}) +export class OrganisationsEinheitContainerComponent implements OnInit { + @Input() public fieldControl: AbstractControl<ResourceUri>; + + public organisationsEinheitResource$: Observable<OrganisationsEinheitResource>; + + constructor( + private readonly dialogService: OzgcloudDialogService, + readonly viewContainerRef: ViewContainerRef, + private readonly service: OrganisationsEinheitService, + ) {} + + ngOnInit(): void { + this.organisationsEinheitResource$ = this.getSelectedResult(); + } + + getSelectedResult(): Observable<OrganisationsEinheitResource> { + return this.service + .getSelectedResult() + .pipe( + tap((organisationsEinheitResource: OrganisationsEinheitResource) => + this.handleResult(organisationsEinheitResource), + ), + ); + } + + handleResult(organisationsEinheitResource: OrganisationsEinheitResource): void { + if (isNotNull(organisationsEinheitResource)) { + this.fieldControl.patchValue(getUrl(organisationsEinheitResource)); + } + } + + public openSearchDialog(): void { + this.dialogService.openInCallingComponentContext<SearchOrganisationsEinheitContainerComponent>( + SearchOrganisationsEinheitContainerComponent, + this.viewContainerRef, + null, //FIXME bitte null nicht als Parameter nehmen + DIALOG_CONFIG, + ); + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.html b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.html new file mode 100644 index 0000000000000000000000000000000000000000..57d9280a6f6c3909e5d5c4494a245a803517d7a3 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.html @@ -0,0 +1,2 @@ +<p class="font-bold">{{ name }}</p> +<p>{{ address }}</p> diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e6eaa1ea513256ec82992961078976755078248 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.spec.ts @@ -0,0 +1,58 @@ +import { OrganisationsEinheitResource } from '@alfa-client/collaboration-shared'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createOrganisationsEinheitResource } from 'libs/collaboration-shared/test/organisations-einheit'; +import { OrganisationsEinheitComponent } from './organisations-einheit.component'; + +describe('OrganisationsEinheitComponent', () => { + let component: OrganisationsEinheitComponent; + let fixture: ComponentFixture<OrganisationsEinheitComponent>; + + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OrganisationsEinheitComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(OrganisationsEinheitComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('set organisationsEinheit', () => { + it('should call update by organisationsEinheit', () => { + component.updateByOrganisationsEinheit = jest.fn(); + + component.organisationsEinheitResource = organisationsEinheitResource; + + expect(component.updateByOrganisationsEinheit).toHaveBeenCalledWith( + organisationsEinheitResource, + ); + }); + }); + + describe('update by organisationsEinheit', () => { + it('should set name', () => { + component.name = null; + + component.updateByOrganisationsEinheit(organisationsEinheitResource); + + expect(component.name).toEqual(organisationsEinheitResource.name); + }); + + it('should set address', () => { + component.address = null; + + component.updateByOrganisationsEinheit(organisationsEinheitResource); + + expect(component.address).toEqual( + `${organisationsEinheitResource.anschrift.strasse} ${organisationsEinheitResource.anschrift.hausnummer}, ${organisationsEinheitResource.anschrift.plz} ${organisationsEinheitResource.anschrift.ort}`, + ); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.ts b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff94e74f550eee641eaf4645b166838d5048badb --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component.ts @@ -0,0 +1,26 @@ +import { Anschrift, OrganisationsEinheitResource } from '@alfa-client/collaboration-shared'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'alfa-organisations-einheit', + templateUrl: './organisations-einheit.component.html', +}) +export class OrganisationsEinheitComponent { + @Input() public set organisationsEinheitResource( + organisationsEinheit: OrganisationsEinheitResource, + ) { + this.updateByOrganisationsEinheit(organisationsEinheit); + } + + public name: string; + public address: string; + + updateByOrganisationsEinheit(organisationsEinheit: OrganisationsEinheitResource): void { + this.name = organisationsEinheit.name; + this.address = this.buildAddress(organisationsEinheit.anschrift); + } + + private buildAddress(anschrift: Anschrift): string { + return `${anschrift.strasse} ${anschrift.hausnummer}, ${anschrift.plz} ${anschrift.ort}`; + } +} diff --git a/alfa-client/libs/collaboration/src/lib/collaboration.module.spec.ts b/alfa-client/libs/collaboration/src/lib/collaboration.module.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fa74dca425307c2116f48587c5179882184daa1 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration.module.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; +import { CollaborationModule } from './collaboration.module'; + +describe('CollaborationModule', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollaborationModule], + }).compileComponents(); + }); + + it('should create', () => { + expect(CollaborationModule).toBeDefined(); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/collaboration.module.ts b/alfa-client/libs/collaboration/src/lib/collaboration.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a66754b9da100acdf4bf900a6669832f24a4ab --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/collaboration.module.ts @@ -0,0 +1,57 @@ +import { CollaborationSharedModule } from '@alfa-client/collaboration-shared'; +import { TechSharedModule } from '@alfa-client/tech-shared'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { + ButtonWithSpinnerComponent, + TextEditorComponent, + TextareaEditorComponent, +} from '@ods/component'; +import { + ButtonComponent, + CloseIconComponent, + CollaborationIconComponent, + InstantSearchComponent, + OfficeIconComponent, + SaveIconComponent, + SearchIconComponent, +} from '@ods/system'; +import { CollaborationInVorgangContainerComponent } from './collaboration-in-vorgang-container/collaboration-in-vorgang-container.component'; +import { CollaborationInVorgangComponent } from './collaboration-in-vorgang-container/collaboration-in-vorgang/collaboration-in-vorgang.component'; +import { CollaborationRequestFormComponent } from './collaboration-in-vorgang-container/collaboration-request-form/collaboration-request-form.component'; +import { OrganisationsEinheitContainerComponent } from './collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit-container.component'; +import { OrganisationsEinheitComponent } from './collaboration-in-vorgang-container/collaboration-request-form/organisations-einheit-container/organisations-einheit/organisations-einheit.component'; +import { SearchOrganisationsEinheitContainerComponent } from './search-organisations-einheit-container/search-organisations-einheit-container.component'; +import { SearchOrganisationsEinheitFormComponent } from './search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component'; + +@NgModule({ + imports: [ + CommonModule, + ButtonComponent, + OfficeIconComponent, + SaveIconComponent, + CloseIconComponent, + SearchIconComponent, + CollaborationSharedModule, + CollaborationIconComponent, + TextEditorComponent, + TextareaEditorComponent, + FormsModule, + ReactiveFormsModule, + InstantSearchComponent, + TechSharedModule, + ButtonWithSpinnerComponent, + ], + declarations: [ + CollaborationInVorgangContainerComponent, + CollaborationInVorgangComponent, + CollaborationRequestFormComponent, + SearchOrganisationsEinheitContainerComponent, + SearchOrganisationsEinheitFormComponent, + OrganisationsEinheitContainerComponent, + OrganisationsEinheitComponent, + ], + exports: [CollaborationInVorgangContainerComponent], +}) +export class CollaborationModule {} diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.html b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.html new file mode 100644 index 0000000000000000000000000000000000000000..9af9814d5c4b8c0e740290016e4e3c8bdaecd4db --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.html @@ -0,0 +1,19 @@ +<div class="my-32 flex h-screen flex-col gap-2"> + <div class="flex gap-48 py-6 lg:gap-96"> + <h1 class="text-xl font-bold text-primary">Zuständige Stelle auswählen</h1> + <ods-button variant="icon" size="fit" (clickEmitter)="closeDialog()"> + <ods-close-icon class="fill-primary" icon /> + </ods-button> + </div> + <alfa-search-organisations-einheit-form + *ngIf="organisationsEinheitStateListResource$ | async as organisationsEinheitStateListResource" + data-test-id="search-organisations-einheit" + [organisationsEinheiten]=" + organisationsEinheitStateListResource.resource + | toEmbeddedResources: organisationsEinheitListLinkRel.ORGANISATIONS_EINHEIT_HEADER_LIST + " + (search)="search($event)" + (selectSearchResult)="selectSearchResult($event)" + (clearSearchResult)="clearSearchResult()" + ></alfa-search-organisations-einheit-form> +</div> diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.spec.ts b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..69c11f3b7ff394d26ec6472161c281fe59ff2cae --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.spec.ts @@ -0,0 +1,219 @@ +import { + OrganisationsEinheitListResource, + OrganisationsEinheitResource, + OrganisationsEinheitService, +} from '@alfa-client/collaboration-shared'; +import { + StateResource, + ToEmbeddedResourcesPipe, + createStateResource, +} from '@alfa-client/tech-shared'; +import { + EventData, + Mock, + dialogRefMock, + getMockComponent, + mock, + triggerEvent, +} from '@alfa-client/test-utils'; +import { DialogRef } from '@angular/cdk/dialog'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import faker from '@faker-js/faker'; +import { ButtonComponent, CloseIconComponent } from '@ods/system'; +import { + createOrganisationsEinheitListResource, + createOrganisationsEinheitResource, + createOrganisationsEinheitResources, +} from 'libs/collaboration-shared/test/organisations-einheit'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { SearchOrganisationsEinheitContainerComponent } from './search-organisations-einheit-container.component'; +import { SearchOrganisationsEinheitFormComponent } from './search-organisations-einheit-form/search-organisations-einheit-form.component'; + +describe('SearchOrganisationsEinheitContainerComponent', () => { + let component: SearchOrganisationsEinheitContainerComponent; + let fixture: ComponentFixture<SearchOrganisationsEinheitContainerComponent>; + + const searchOrganisationsEinheitComp: string = getDataTestIdOf('search-organisations-einheit'); + + const service: Mock<OrganisationsEinheitService> = mock(OrganisationsEinheitService); + + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + const organisationsEinheitResources: OrganisationsEinheitResource[] = + createOrganisationsEinheitResources(); + const organisationsEinheitListResource: OrganisationsEinheitListResource = + createOrganisationsEinheitListResource(organisationsEinheitResources); + const organisationsEinheitStateListResource: StateResource<OrganisationsEinheitListResource> = + createStateResource(organisationsEinheitListResource); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + SearchOrganisationsEinheitContainerComponent, + ToEmbeddedResourcesPipe, + MockComponent(SearchOrganisationsEinheitFormComponent), + MockComponent(ButtonComponent), + MockComponent(CloseIconComponent), + ], + providers: [ + { + provide: OrganisationsEinheitService, + useValue: service, + }, + { + provide: DialogRef, + useValue: dialogRefMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchOrganisationsEinheitContainerComponent); + component = fixture.componentInstance; + component.organisationsEinheitStateListResource$ = of(organisationsEinheitStateListResource); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call service', () => { + component.ngOnInit(); + + expect(service.getSearchResultList).toHaveBeenCalled(); + }); + }); + + describe('onKeyDownHandler', () => { + it('should prevent default behavior for enter key', () => { + const keyboardEvent: KeyboardEvent = { + ...new KeyboardEvent('enter'), + key: 'Enter', + preventDefault: jest.fn(), + }; + + component.onKeyDownHandler(keyboardEvent); + + expect(keyboardEvent.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('search organisationsEinheit component', () => { + beforeEach(() => { + component.organisationsEinheitStateListResource$ = of(organisationsEinheitStateListResource); + fixture.detectChanges(); + }); + + it('should be called with organisationsEinheiten', () => { + const comp: SearchOrganisationsEinheitFormComponent = + getMockComponent<SearchOrganisationsEinheitFormComponent>( + fixture, + SearchOrganisationsEinheitFormComponent, + ); + + expect(comp.organisationsEinheiten).toEqual(organisationsEinheitResources); + }); + + it('should call search on openSearchDialog output', () => { + component.search = jest.fn(); + const searchBy: string = faker.random.word(); + const eventData: EventData<SearchOrganisationsEinheitContainerComponent> = { + fixture, + elementSelector: searchOrganisationsEinheitComp, + name: 'search', + data: searchBy, + }; + + triggerEvent(eventData); + + expect(component.search).toHaveBeenCalledWith(searchBy); + }); + + it('should call selectSearchResult on selectSearchResult output', () => { + component.selectSearchResult = jest.fn(); + + const eventData: EventData<SearchOrganisationsEinheitContainerComponent> = { + fixture, + elementSelector: searchOrganisationsEinheitComp, + name: 'selectSearchResult', + data: organisationsEinheitResource, + }; + + triggerEvent(eventData); + + expect(component.selectSearchResult).toHaveBeenCalledWith(organisationsEinheitResource); + }); + + it('should call clearSearchResult', () => { + component.clearSearchResult = jest.fn(); + + const eventData: EventData<SearchOrganisationsEinheitContainerComponent> = { + fixture, + elementSelector: searchOrganisationsEinheitComp, + name: 'clearSearchResult', + data: organisationsEinheitResource, + }; + + triggerEvent(eventData); + + expect(component.clearSearchResult).toHaveBeenCalled(); + }); + }); + + describe('search', () => { + const searchBy: string = faker.random.word(); + + it('should call service', () => { + component.search(searchBy); + + expect(service.search).toHaveBeenCalledWith(searchBy); + }); + }); + + describe('select search result', () => { + it('should set select result', () => { + component.selectSearchResult(organisationsEinheitResource); + + expect(service.selectSearchResult).toHaveBeenCalledWith(organisationsEinheitResource); + }); + + it('should call service', () => { + component.selectSearchResult(organisationsEinheitResource); + + expect(service.clearSearchResult).toHaveBeenCalled(); + }); + + it('should close dialog', () => { + component.selectSearchResult(organisationsEinheitResource); + + expect(dialogRefMock.close).toHaveBeenCalled(); + }); + }); + + describe('clear search result', () => { + it('should call service', () => { + component.clearSearchResult(); + + expect(service.clearSearchResult).toHaveBeenCalled(); + }); + }); + + describe('close dialog', () => { + it('should clear search result', () => { + component.clearSearchResult = jest.fn(); + + component.closeDialog(); + + expect(component.clearSearchResult).toHaveBeenCalled(); + }); + + it('should close dialog', () => { + component.closeDialog(); + + expect(dialogRefMock.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.ts b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..14a54da18681ed93697757c2cbcd13e81346414c --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-container.component.ts @@ -0,0 +1,56 @@ +import { + OrganisationsEinheitListLinkRel, + OrganisationsEinheitService, +} from '@alfa-client/collaboration-shared'; +import { StateResource } from '@alfa-client/tech-shared'; +import { DialogRef } from '@angular/cdk/dialog'; +import { Component, HostListener, OnInit } from '@angular/core'; +import { + OrganisationsEinheitListResource, + OrganisationsEinheitResource, +} from 'libs/collaboration-shared/src/lib/organisations-einheit.model'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'alfa-search-organisations-einheit-container', + templateUrl: './search-organisations-einheit-container.component.html', +}) +export class SearchOrganisationsEinheitContainerComponent implements OnInit { + public organisationsEinheitStateListResource$: Observable< + StateResource<OrganisationsEinheitListResource> + >; + + public readonly organisationsEinheitListLinkRel = OrganisationsEinheitListLinkRel; + + constructor( + private readonly service: OrganisationsEinheitService, + private readonly dialogRef: DialogRef, + ) {} + + ngOnInit(): void { + this.organisationsEinheitStateListResource$ = this.service.getSearchResultList(); + } + + @HostListener('document:keydown', ['$event']) onKeyDownHandler(e: KeyboardEvent) { + if (e.key === 'Enter') e.preventDefault(); + } + + public search(searchBy: string): void { + this.service.search(searchBy); + } + + public selectSearchResult(organisationsEinheit: OrganisationsEinheitResource): void { + this.service.selectSearchResult(organisationsEinheit); + this.service.clearSearchResult(); + this.dialogRef.close(); + } + + public clearSearchResult(): void { + this.service.clearSearchResult(); + } + + public closeDialog(): void { + this.clearSearchResult(); + this.dialogRef.close(); + } +} diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.html b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.html new file mode 100644 index 0000000000000000000000000000000000000000..7252665d682c7686f3ae4b63e4cce1567edf4f9d --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.html @@ -0,0 +1,12 @@ +<form [formGroup]="formService.form"> + <ods-instant-search + data-test-id="search" + placeholder="Name des Amts oder Adresse eingeben" + [control]="formService.form.controls.search" + [searchResults]="searchResults" + (searchResultSelected)="selectSearchResult.emit($event.data)" + (searchQueryChanged)="search.emit($event.searchBy)" + (searchQueryCleared)="clearSearchResult.emit()" + (searchResultClosed)="clearSearchResult.emit()" + ></ods-instant-search> +</form> diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.spec.ts b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..11fa9f5ebd5cfdcca46bf05fd71833602391e270 --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.spec.ts @@ -0,0 +1,183 @@ +import { OrganisationsEinheitResource } from '@alfa-client/collaboration-shared'; +import { EventData, Mock, getMockComponent, mock, triggerEvent } from '@alfa-client/test-utils'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import faker from '@faker-js/faker'; +import { InstantSearchComponent } from '@ods/system'; +import { createOrganisationsEinheitResource } from 'libs/collaboration-shared/test/organisations-einheit'; +import { InstantSearchResult } from 'libs/design-system/src/lib/instant-search/instant-search/instant-search.model'; +import { createInstantSearchResult } from 'libs/design-system/src/test/search'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { MockComponent } from 'ng-mocks'; +import { SearchOrganisationsEinheitFormService } from '../search-organisations-einheit.formservice'; +import { SearchOrganisationsEinheitFormComponent } from './search-organisations-einheit-form.component'; + +describe('SearchOrganisationsEinheitFormComponent', () => { + let component: SearchOrganisationsEinheitFormComponent; + let fixture: ComponentFixture<SearchOrganisationsEinheitFormComponent>; + + const searchComp: string = getDataTestIdOf('search'); + + const formService: Mock<SearchOrganisationsEinheitFormService> = mock( + SearchOrganisationsEinheitFormService, + ); + + const instantSearchResult: InstantSearchResult<OrganisationsEinheitResource> = + createInstantSearchResult<OrganisationsEinheitResource>(); + const organisationsEinheitResource: OrganisationsEinheitResource = + createOrganisationsEinheitResource(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + declarations: [ + SearchOrganisationsEinheitFormComponent, + MockComponent(InstantSearchComponent), + ], + providers: [ + { + provide: SearchOrganisationsEinheitFormService, + useValue: formService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchOrganisationsEinheitFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('set organisationsEinheiten', () => { + it('should map organisationsEinheitResources', () => { + component.mapOrganisationsEinheitResources = jest.fn(); + + component.organisationsEinheiten = [organisationsEinheitResource]; + + expect(component.mapOrganisationsEinheitResources).toHaveBeenCalledWith([ + organisationsEinheitResource, + ]); + }); + }); + + describe('map organisationsEinheit Resources', () => { + it('should call mapToInstantSearchResult', () => { + component.mapToInstantantSearchResult = jest.fn(); + + component.mapOrganisationsEinheitResources([organisationsEinheitResource]); + + expect(component.mapToInstantantSearchResult).toHaveBeenCalledWith( + organisationsEinheitResource, + ); + }); + + it('should set searchResults', () => { + component.mapToInstantantSearchResult = jest.fn().mockReturnValue(instantSearchResult); + + component.mapOrganisationsEinheitResources([organisationsEinheitResource]); + + expect(component.searchResults).toEqual([instantSearchResult]); + }); + }); + + describe('map to instand search result', () => { + it('should map titel', () => { + const instantSearchResult: InstantSearchResult<OrganisationsEinheitResource> = + component.mapToInstantantSearchResult(organisationsEinheitResource); + + expect(instantSearchResult.title).toBe(organisationsEinheitResource.name); + }); + it('should map description', () => { + const instantSearchResult: InstantSearchResult<OrganisationsEinheitResource> = + component.mapToInstantantSearchResult(organisationsEinheitResource); + + const expectedDescription: string = `${organisationsEinheitResource.anschrift.strasse} ${organisationsEinheitResource.anschrift.hausnummer}, ${organisationsEinheitResource.anschrift.plz} ${organisationsEinheitResource.anschrift.ort}`; + expect(instantSearchResult.description).toBe(expectedDescription); + }); + + it('should map data', () => { + const instantSearchResult: InstantSearchResult<OrganisationsEinheitResource> = + component.mapToInstantantSearchResult(organisationsEinheitResource); + + expect(instantSearchResult.data).toBe(organisationsEinheitResource); + }); + }); + + describe('instant search component', () => { + it('should be called with search results', () => { + component.searchResults = [instantSearchResult]; + + fixture.detectChanges(); + + expect(getInstantSearchComponent().searchResults).toEqual([instantSearchResult]); + }); + + function getInstantSearchComponent(): InstantSearchComponent { + return getMockComponent<InstantSearchComponent>(fixture, InstantSearchComponent); + } + + it('should emit selected search result on searchResultSelected output', () => { + const selectSearchResultSpy: jest.SpyInstance = (component.selectSearchResult.emit = + jest.fn()); + const eventData: EventData<SearchOrganisationsEinheitFormComponent> = { + fixture, + elementSelector: searchComp, + name: 'searchResultSelected', + data: { data: organisationsEinheitResource }, + }; + + triggerEvent(eventData); + + expect(selectSearchResultSpy).toHaveBeenCalledWith(organisationsEinheitResource); + }); + + it('should emit search on searchQueryChanged output', () => { + const searchSpy: jest.SpyInstance = (component.search.emit = jest.fn()); + const searchBy: string = faker.random.word(); + const eventData: EventData<SearchOrganisationsEinheitFormComponent> = { + fixture, + elementSelector: searchComp, + name: 'searchQueryChanged', + data: { searchBy }, + }; + + triggerEvent(eventData); + + expect(searchSpy).toHaveBeenCalledWith(searchBy); + }); + + describe('should emit clear search result', () => { + let clearSearchResultSpy: jest.SpyInstance; + let eventData: EventData<SearchOrganisationsEinheitFormComponent>; + + beforeEach(() => { + clearSearchResultSpy = component.clearSearchResult.emit = jest.fn(); + eventData = { + fixture, + elementSelector: searchComp, + name: 'TBD', + data: { searchBy: faker.random.word() }, + }; + }); + + it('on searchResultClosed output', () => { + eventData = { ...eventData, name: 'searchResultClosed' }; + + triggerEvent(eventData); + + expect(clearSearchResultSpy).toHaveBeenCalledWith(); + }); + + it('on searchQueryCleared output', () => { + eventData = { ...eventData, name: 'searchResultClosed' }; + + triggerEvent(eventData); + + expect(clearSearchResultSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.ts b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e144eb1257283beea80b37d9b1d79b1a1fee50ab --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit-form/search-organisations-einheit-form.component.ts @@ -0,0 +1,41 @@ +import { OrganisationsEinheitResource } from '@alfa-client/collaboration-shared'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { InstantSearchResult } from '@ods/system'; +import { SearchOrganisationsEinheitFormService } from '../search-organisations-einheit.formservice'; + +@Component({ + selector: 'alfa-search-organisations-einheit-form', + templateUrl: './search-organisations-einheit-form.component.html', + providers: [SearchOrganisationsEinheitFormService], +}) +export class SearchOrganisationsEinheitFormComponent { + @Input() set organisationsEinheiten(organisationsEinheiten: OrganisationsEinheitResource[]) { + this.mapOrganisationsEinheitResources(organisationsEinheiten); + } + + @Output() public search: EventEmitter<string> = new EventEmitter(); + @Output() public selectSearchResult: EventEmitter<OrganisationsEinheitResource> = + new EventEmitter(); + @Output() public clearSearchResult: EventEmitter<string> = new EventEmitter(); + + public searchResults: InstantSearchResult<OrganisationsEinheitResource>[]; + + constructor(public formService: SearchOrganisationsEinheitFormService) {} + + mapOrganisationsEinheitResources(organisationsEinheiten: OrganisationsEinheitResource[]): void { + this.searchResults = organisationsEinheiten.map( + (organisationsEinheiten: OrganisationsEinheitResource) => + this.mapToInstantantSearchResult(organisationsEinheiten), + ); + } + + mapToInstantantSearchResult( + organisationsEinheit: OrganisationsEinheitResource, + ): InstantSearchResult<OrganisationsEinheitResource> { + return <any>{ + title: organisationsEinheit.name, + description: `${organisationsEinheit.anschrift.strasse} ${organisationsEinheit.anschrift.hausnummer}, ${organisationsEinheit.anschrift.plz} ${organisationsEinheit.anschrift.ort}`, + data: organisationsEinheit, + }; + } +} diff --git a/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit.formservice.ts b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit.formservice.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b63e2097a660615148dce5468d7b017976b64ef --- /dev/null +++ b/alfa-client/libs/collaboration/src/lib/search-organisations-einheit-container/search-organisations-einheit.formservice.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { ResourceUri } from '@ngxp/rest'; + +@Injectable() +export class SearchOrganisationsEinheitFormService { + public form: FormGroup; + + public readonly SEARCH_FIELD: string = 'search'; + + constructor(private formBuilder: FormBuilder) { + this.initForm(); + } + + private initForm(): void { + this.form = this.formBuilder.group({ + [this.SEARCH_FIELD]: new FormControl<ResourceUri>(null), + }); + } +} diff --git a/alfa-client/libs/collaboration/src/test-setup.ts b/alfa-client/libs/collaboration/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b07c0bac34c40aa6afeef02c18c8db08f79de48 --- /dev/null +++ b/alfa-client/libs/collaboration/src/test-setup.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom'; +import 'jest-preset-angular/setup-jest'; + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +getTestBed().resetTestEnvironment(); +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { + teardown: { destroyAfterEach: false }, + errorOnUnknownProperties: true, + errorOnUnknownElements: true, +}); diff --git a/alfa-client/libs/collaboration/tsconfig.json b/alfa-client/libs/collaboration/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7cc6baf2f58ed5ccfba098131996f579979e9f18 --- /dev/null +++ b/alfa-client/libs/collaboration/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "target": "es2022" + } +} diff --git a/alfa-client/libs/collaboration/tsconfig.lib.json b/alfa-client/libs/collaboration/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..4cab05d46338c6e9d4dfe6512fd7eb7f60340d3d --- /dev/null +++ b/alfa-client/libs/collaboration/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/alfa-client/libs/collaboration/tsconfig.spec.json b/alfa-client/libs/collaboration/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..7870b7c011681fb77d6114001f44d3eeca69975b --- /dev/null +++ b/alfa-client/libs/collaboration/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/alfa-client/libs/command-shared/src/lib/+state/command.effects.spec.ts b/alfa-client/libs/command-shared/src/lib/+state/command.effects.spec.ts index c461fcffe58c1bff6b679c9175b70af066484b98..2ee1199a7ec572ca5c33e21ed788c098f2b95015 100644 --- a/alfa-client/libs/command-shared/src/lib/+state/command.effects.spec.ts +++ b/alfa-client/libs/command-shared/src/lib/+state/command.effects.spec.ts @@ -1,4 +1,3 @@ -import { TestBed } from '@angular/core/testing'; import { ApiError, ApiErrorAction, @@ -7,6 +6,7 @@ import { } from '@alfa-client/tech-shared'; import { Mock, mock } from '@alfa-client/test-utils'; import { SnackBarService } from '@alfa-client/ui'; +import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store, createAction, props } from '@ngrx/store'; import { TypedAction } from '@ngrx/store/src/models'; @@ -26,7 +26,12 @@ import { CommandLinkRel } from '../command.linkrel'; import { CREATE_COMMAND_MESSAGE_BY_ORDER, CommandErrorMessage } from '../command.message'; import { CommandListResource, CommandResource, CreateCommandProps } from '../command.model'; import { CommandRepository } from '../command.repository'; -import { CommandProps, LoadCommandListSuccessProps, createCommandFailure } from './command.actions'; +import { + CommandProps, + LoadCommandListSuccessProps, + SnackBarProps, + createCommandFailure, +} from './command.actions'; import { CommandEffects } from './command.effects'; import * as CommandActions from './command.actions'; @@ -35,13 +40,17 @@ describe('CommandEffects', () => { let actions: Observable<Action>; let effects: CommandEffects; - const repository: Mock<CommandRepository> = mock(CommandRepository); - const snackBarService: Mock<SnackBarService> = mock(SnackBarService); - const store: Mock<SnackBarService> = mock(SnackBarService); + let repository: Mock<CommandRepository>; + let snackBarService: Mock<SnackBarService>; + let store: Mock<Store>; let testScheduler: TestScheduler; beforeEach(() => { + repository = mock(CommandRepository); + snackBarService = mock(SnackBarService); + store = mock(Store); + testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected)); TestBed.configureTestingModule({ @@ -374,7 +383,8 @@ describe('CommandEffects', () => { const createCommandProps: CreateCommandProps = createCreateCommandProps(); const command: CommandResource = createCommandResource(); - it('should show snackBar on existing snackBarMessage', () => { + it('should call handle snackbar by command', () => { + effects.handleSnackbarByCommand = jest.fn(); const showSnackbarAction: TypedAction<string> = CommandActions.showSnackbar({ createCommandProps, command, @@ -383,6 +393,19 @@ describe('CommandEffects', () => { actions = of(showSnackbarAction); effects.showSnackbar$.subscribe(); + expect(effects.handleSnackbarByCommand).toHaveBeenCalled(); + }); + }); + + describe('handle snackbar by command', () => { + const createCommandProps: CreateCommandProps = createCreateCommandProps(); + const command: CommandResource = createCommandResource(); + + it('should show snackBar on existing snackBarMessage', () => { + const snackBarProps: SnackBarProps = { createCommandProps, command }; + + effects.handleSnackbarByCommand(snackBarProps); + expect(snackBarService.show).toHaveBeenCalledWith( command, createCommandProps.snackBarMessage, @@ -390,13 +413,12 @@ describe('CommandEffects', () => { }); it('should show snackBar on undefined snackBarMessage', () => { - const showSnackbarAction: TypedAction<string> = CommandActions.showSnackbar({ + const snackBarProps: SnackBarProps = { createCommandProps: { ...createCommandProps, snackBarMessage: undefined }, command, - }); + }; - actions = of(showSnackbarAction); - effects.showSnackbar$.subscribe(); + effects.handleSnackbarByCommand(snackBarProps); expect(snackBarService.show).toHaveBeenCalledWith( command, @@ -404,16 +426,24 @@ describe('CommandEffects', () => { ); }); - //Faellt um, wenn man ihn mit den anderen Tests zusammen laufen laesst - it.skip('FIXME should NOT show snackBar on empty snackBarMessage', () => { - const showSnackbarAction: TypedAction<string> = CommandActions.showSnackbar({ + it('should NOT show snackBar on empty snackBarMessage', () => { + const snackBarProps: SnackBarProps = { createCommandProps: { ...createCommandProps, snackBarMessage: EMPTY_STRING }, command, - }); - snackBarService.show.mockClear(); + }; - effects.showSnackbar$.subscribe(); - actions = of(showSnackbarAction); + effects.handleSnackbarByCommand(snackBarProps); + + expect(snackBarService.show).not.toHaveBeenCalled(); + }); + + it('should NOT show snackBar on existing error message', () => { + const snackBarProps: SnackBarProps = { + createCommandProps, + command: { ...command, errorMessage: 'dummyErrorMessage' }, + }; + + effects.handleSnackbarByCommand(snackBarProps); expect(snackBarService.show).not.toHaveBeenCalled(); }); diff --git a/alfa-client/libs/command-shared/src/lib/+state/command.effects.ts b/alfa-client/libs/command-shared/src/lib/+state/command.effects.ts index 3d7151381f57a1c9843bf17179f320bd3c28022c..76c42d3899bc776736f109e5af66d73cccd23aa1 100644 --- a/alfa-client/libs/command-shared/src/lib/+state/command.effects.ts +++ b/alfa-client/libs/command-shared/src/lib/+state/command.effects.ts @@ -1,14 +1,21 @@ -import { Injectable } from '@angular/core'; +import { EMPTY_STRING } from '@alfa-client/tech-shared'; import { SnackBarService } from '@alfa-client/ui'; +import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TypedAction } from '@ngrx/store/src/models'; +import { isEmpty, isEqual, isUndefined } from 'lodash-es'; import { of } from 'rxjs'; import { catchError, delay, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { COMMAND_ERROR_MESSAGES, CREATE_COMMAND_MESSAGE_BY_ORDER } from '../command.message'; import { CommandListResource, CommandResource, CreateCommandProps } from '../command.model'; import { CommandRepository } from '../command.repository'; -import { hasCommandError, isConcurrentModification, isPending, isRevokeable } from '../command.util'; +import { + hasCommandError, + isConcurrentModification, + isPending, + isRevokeable, +} from '../command.util'; import { CommandProps, LoadCommandListProps, @@ -27,8 +34,6 @@ import { showRevokeSnackbar, showSnackbar, } from './command.actions'; -import { isEqual, isUndefined } from 'lodash-es'; -import { EMPTY_STRING } from '@alfa-client/tech-shared'; @Injectable() export class CommandEffects { @@ -99,6 +104,8 @@ export class CommandEffects { } if (hasCommandError(command) && isConcurrentModification(command.errorMessage)) { this.showError(command); + //FIXME Anstelle der createCommandSucess Action sollte eine createCommandFailure Action geworfen werden. + //Hierzu muss ein HttpErrorResponse für die errorMessage definieren werden return [createCommandSuccess({ command }), publishConcurrentModificationAction()]; } return [createCommandSuccess({ command }), showSnackbar({ createCommandProps, command })]; @@ -131,15 +138,20 @@ export class CommandEffects { () => this.actions$.pipe( ofType(showSnackbar), - tap((props: SnackBarProps) => { - if (!isEqual(props.createCommandProps.snackBarMessage, EMPTY_STRING)) { - this.snackbarService.show(props.command, this.getSnackBarMessage(props)); - } - }), + tap((props: SnackBarProps) => this.handleSnackbarByCommand(props)), ), { dispatch: false }, ); + handleSnackbarByCommand(props: SnackBarProps): void { + if ( + !isEqual(props.createCommandProps.snackBarMessage, EMPTY_STRING) && + isEmpty(props.command.errorMessage) + ) { + this.snackbarService.show(props.command, this.getSnackBarMessage(props)); + } + } + private getSnackBarMessage(props: SnackBarProps): string { return isUndefined(props.createCommandProps.snackBarMessage) ? CREATE_COMMAND_MESSAGE_BY_ORDER[props.command.order] diff --git a/alfa-client/libs/command-shared/src/lib/command.model.ts b/alfa-client/libs/command-shared/src/lib/command.model.ts index 49fa06eef50a1273187099e6d2f604d14a80df07..a36da0c8c766e2ec352ae2418f38fcc24569ad27 100644 --- a/alfa-client/libs/command-shared/src/lib/command.model.ts +++ b/alfa-client/libs/command-shared/src/lib/command.model.ts @@ -86,6 +86,7 @@ export enum CommandOrder { CREATE_BESCHEID_DOCUMENT_FROM_FILE = 'CREATE_BESCHEID_DOCUMENT_FROM_FILE', CREATE_BESCHEID_DOCUMENT = 'CREATE_BESCHEID_DOCUMENT', SEND_BESCHEID = 'SEND_BESCHEID', + CREATE_COLLABORATION_REQUEST = 'CREATE_COLLABORATION_REQUEST', } //TODO rename CreateCommandProps -> CreateCommandActionProps diff --git a/alfa-client/libs/design-system/src/index.ts b/alfa-client/libs/design-system/src/index.ts index 57c220dc85cf06f66d1e23edd66bc7d9bff947ea..75cf45074d60ef259b865f81f143896ef3c778c1 100644 --- a/alfa-client/libs/design-system/src/index.ts +++ b/alfa-client/libs/design-system/src/index.ts @@ -10,15 +10,24 @@ export * from './lib/form/file-upload-button/file-upload-button.component'; export * from './lib/form/radio-button-card/radio-button-card.component'; export * from './lib/form/text-input/text-input.component'; export * from './lib/form/textarea/textarea.component'; +export * from './lib/icons/admin-logo-icon/admin-logo-icon.component'; export * from './lib/icons/attachment-icon/attachment-icon.component'; export * from './lib/icons/bescheid-generate-icon/bescheid-generate-icon.component'; export * from './lib/icons/bescheid-upload-icon/bescheid-upload-icon.component'; export * from './lib/icons/close-icon/close-icon.component'; +export * from './lib/icons/collaboration-icon/collaboration-icon.component'; export * from './lib/icons/exclamation-icon/exclamation-icon.component'; export * from './lib/icons/file-icon/file-icon.component'; export * from './lib/icons/iconVariants'; +export * from './lib/icons/logout-icon/logout-icon.component'; +export * from './lib/icons/office-icon/office-icon.component'; export * from './lib/icons/save-icon/save-icon.component'; +export * from './lib/icons/search-icon/search-icon.component'; export * from './lib/icons/send-icon/send-icon.component'; export * from './lib/icons/spinner-icon/spinner-icon.component'; export * from './lib/icons/stamp-icon/stamp-icon.component'; +export * from './lib/instant-search/instant-search/instant-search.component'; +export * from './lib/instant-search/instant-search/instant-search.model'; +export * from './lib/popup/popup-list-item/popup-list-item.component'; +export * from './lib/popup/popup/popup.component'; export * from './lib/testbtn/testbtn.component'; diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0848c08f798f58fd91f850597ce0d15f267e065 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AriaLiveRegionComponent } from './aria-live-region.component'; + +describe('AriaLiveRegionComponent', () => { + let component: AriaLiveRegionComponent; + let fixture: ComponentFixture<AriaLiveRegionComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AriaLiveRegionComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AriaLiveRegionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cec9afff4c23502077704daea8b04f125158bdd --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ods-aria-live-region', + standalone: true, + imports: [CommonModule], + template: `<span aria-live="polite" class="sr-only" role="status">{{ text }}</span>`, +}) +export class AriaLiveRegionComponent { + @Input() text: string = ''; +} diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..7af950ebf415e5e2a634be01a2edaa32a9d78a01 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts @@ -0,0 +1,25 @@ +import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular'; +import { AriaLiveRegionComponent } from './aria-live-region.component'; + +const meta: Meta<AriaLiveRegionComponent> = { + title: 'Aria live region', + component: AriaLiveRegionComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<AriaLiveRegionComponent>; + +export const Default: Story = { + args: { + text: '', + }, + render: (args) => ({ + props: args, + template: ` + <h2>This component is suitable for screen reader. Fill text field to make screen reader read it aloud.</h2> + <ods-aria-live-region ${argsToTemplate(args)} /> + `, + }), +}; diff --git a/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts b/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts index 485acda2600b59166e331fe38bffba7392c1e783..7d8f0fbec59aeb00afc99d2377c1902abd20c73d 100644 --- a/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts +++ b/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts @@ -1,7 +1,8 @@ -import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; import { DownloadButtonComponent } from '../../../../design-component/src/lib/download-button/download-button.component'; +import { AttachmentHeaderComponent } from '../attachment-header/attachment-header.component'; import { AttachmentComponent } from '../attachment/attachment.component'; import { AttachmentWrapperComponent } from './attachment-wrapper.component'; @@ -17,7 +18,12 @@ const meta: Meta<AttachmentWrapperComponent> = { }, decorators: [ moduleMetadata({ - imports: [AttachmentWrapperComponent, AttachmentComponent, DownloadButtonComponent], + imports: [ + AttachmentWrapperComponent, + AttachmentComponent, + DownloadButtonComponent, + AttachmentHeaderComponent, + ], }), ], excludeStories: /.*Data$/, @@ -28,18 +34,11 @@ export default meta; type Story = StoryObj<AttachmentWrapperComponent>; export const Default: Story = { - args: { - title: 'Anhänge', - }, - argTypes: { - title: { - description: 'Title for group of files', - }, - }, - render: (args) => ({ - props: args, - template: `<ods-attachment-wrapper ${argsToTemplate(args)}> - <ods-download-button action-buttons /> + render: () => ({ + template: `<ods-attachment-wrapper> + <ods-attachment-header title="Anhänge"> + <ods-download-button action-buttons /> + </ods-attachment-header> <ods-attachment caption="Attachment" description="200 kB" fileType="pdf"></ods-attachment> <ods-attachment caption="Second attachment" description="432 kB" fileType="doc"></ods-attachment> </ods-attachment-wrapper>`, diff --git a/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts b/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts index 9ed55dd799191cfb77c2462d25a357425f60c2ab..586fcb8a140c698f4e90a5d8b0b53f0e97556ac8 100644 --- a/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts +++ b/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts @@ -13,7 +13,7 @@ import { StampIconComponent } from '../icons/stamp-icon/stamp-icon.component'; ><ods-stamp-icon size="medium" class="fill-bewilligt" />Bewilligt am {{ dateText }}</span > <span class="flex items-center gap-2" *ngIf="!bewilligt" - ><ods-close-icon size="medium" class="fill-abgelehnt" />Abgelehnt am + ><ods-close-icon class="fill-abgelehnt" />Abgelehnt am {{ dateText }} </span> <span diff --git a/alfa-client/libs/design-system/src/lib/button/button.component.ts b/alfa-client/libs/design-system/src/lib/button/button.component.ts index 58ba7ec0d96c8c0eb0516c9fbc367b885d686837..277dbfbf830d140408f411526c2e84f33748e379 100644 --- a/alfa-client/libs/design-system/src/lib/button/button.component.ts +++ b/alfa-client/libs/design-system/src/lib/button/button.component.ts @@ -6,7 +6,10 @@ import { IconVariants } from '../icons/iconVariants'; import { SpinnerIconComponent } from '../icons/spinner-icon/spinner-icon.component'; export const buttonVariants = cva( - 'flex cursor-pointer items-center gap-4 rounded-md font-medium disabled:cursor-wait text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary', + [ + 'flex items-center gap-4 rounded-md disabled:cursor-wait text-sm font-medium box-border', + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus', + ], { variants: { variant: { diff --git a/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts b/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts index 96ec7406916b5f4773f6aa448ad12112c913ebfc..dd949aeef16caab452a0ee6c8aee76bb0624cd79 100644 --- a/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts +++ b/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts @@ -53,7 +53,7 @@ export const Default: Story = { value="abgelehnt" variant="bescheid_abgelehnt" > - <ods-close-icon class="fill-abgelehnt" /> + <ods-close-icon class="fill-abgelehnt" size="large" /> </ods-radio-button-card> </div>`, }), 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 e136a50bb991af6df9384d7ceca287879b3cdcc8..a534a5276d34b072c1b8edee6e95e362a5296d77 100644 --- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts @@ -1,12 +1,15 @@ -import { convertForDataTest, TechSharedModule } from '@alfa-client/tech-shared'; +import { convertForDataTest, EMPTY_STRING, TechSharedModule } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; -import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { cva, VariantProps } from 'class-variance-authority'; import { ErrorMessageComponent } from '../error-message/error-message.component'; const textInputVariants = cva( - 'block w-full rounded-lg border bg-background-50 px-3 py-2 text-base leading-5 text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', + [ + 'w-full box-border rounded-lg border bg-background-50 px-3 py-2 text-base text-text leading-5', + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', + ], { variants: { variant: { @@ -28,23 +31,40 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; standalone: true, imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule, TechSharedModule], template: ` - <div> - <label [for]="id" class="text-md mb-2 block font-medium text-text"> + <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> </label> <div class="mt-2"> + <div + *ngIf="withPrefix" + class="pointer-events-none absolute bottom-2 left-2 flex size-6 items-center justify-center" + > + <ng-content select="[prefix]" /> + </div> <input type="text" [id]="id" [formControl]="fieldControl" - [ngClass]="textInputVariants({ variant })" + [ngClass]="[ + textInputVariants({ variant }), + withPrefix ? 'pl-10' : '', + withSuffix ? 'pr-10' : '', + ]" [placeholder]="placeholder" [autocomplete]="autocomplete" [attr.aria-required]="required" [attr.aria-invalid]="variant === 'error'" [attr.data-test-id]="(inputLabel | convertForDataTest) + '-text-input'" + (click)="clickEmitter.emit()" #inputElement /> + <div + *ngIf="withSuffix" + class="absolute bottom-2 right-2 flex size-6 items-center justify-center" + > + <ng-content select="[suffix]" /> + </div> </div> <ng-content select="[error]"></ng-content> </div> @@ -60,8 +80,11 @@ export class TextInputComponent { @Input() placeholder: string = ''; @Input() autocomplete: string = 'off'; @Input() variant: TextInputVariants['variant']; - @Input() fieldControl: FormControl; + @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING); @Input() required: boolean = false; + @Input() withPrefix: boolean = false; + @Input() withSuffix: boolean = false; + @Input() showLabel: boolean = true; @Input() set focus(value: boolean) { if (value && this.inputElement) { @@ -69,6 +92,8 @@ export class TextInputComponent { } } + @Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + inputLabel: string; id: string; textInputVariants = textInputVariants; 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 90a4fa85f7dcb0b5a55f6a926a0d08ab63e332a9..2c4340b9f30232e900ef8a4ac8c845adf4765d41 100644 --- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts @@ -1,11 +1,14 @@ -import { TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared'; +import { EMPTY_STRING, TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { VariantProps, cva } from 'class-variance-authority'; const textareaVariants = cva( - 'block w-full rounded-lg border bg-background-50 px-3 py-2 text-base text-text leading-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', + [ + 'w-full box-border rounded-lg border bg-background-50 px-3 py-2 text-base text-text leading-5', + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', + ], { variants: { variant: { @@ -59,7 +62,7 @@ export class TextareaComponent { @Input() rows: number = 3; @Input() autocomplete: string = 'off'; @Input() variant: TextareaVariants['variant']; - @Input() fieldControl: FormControl; + @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING); @Input() required: boolean = false; @Input() set focus(value: boolean) { diff --git a/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.html b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.html new file mode 100644 index 0000000000000000000000000000000000000000..cf830b9d725556588fd4af11866b4ceca1427be6 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.html @@ -0,0 +1,175 @@ +<svg + width="143" + height="38" + viewBox="0 0 143 38" + fill="none" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" +> + <g clip-path="url(#clip0_299_6811)"> + <path + d="M28.7467 17.9064H19.1427C17.9555 17.9064 16.9927 16.981 16.9927 15.841C16.9927 14.701 17.9555 13.7755 19.1427 13.7755H28.7467C29.9339 13.7755 30.8967 14.701 30.8967 15.841C30.8967 16.981 29.9339 17.9064 28.7467 17.9064Z" + fill="#003064" + /> + <path + d="M32.707 24.8786H22.633C21.4458 24.8786 20.483 23.9531 20.483 22.8131C20.483 21.6732 21.4458 20.7477 22.633 20.7477H32.707C33.8942 20.7477 34.857 21.6732 34.857 22.8131C34.857 23.9531 33.8942 24.8786 32.707 24.8786Z" + fill="url(#paint0_linear_299_6811)" + /> + <mask + id="mask0_299_6811" + style="mask-type: luminance" + maskUnits="userSpaceOnUse" + x="7" + y="20" + width="19" + height="19" + > + <path + d="M25.2048 32.7939C25.2048 35.6607 22.7751 38.0001 19.7873 38.0001C16.7994 38.0001 14.5472 35.851 14.3853 33.1244C14.3853 28.9301 11.3478 25.4022 7.27356 24.4524C9.08983 23.8899 10.5312 22.5151 11.1106 20.7694C12.099 24.6899 15.7656 27.6026 20.1082 27.6026C22.9625 27.7577 25.2034 30.035 25.2034 32.7939H25.2048Z" + fill="white" + /> + </mask> + <g mask="url(#mask0_299_6811)"> + <mask + id="mask1_299_6811" + style="mask-type: luminance" + maskUnits="userSpaceOnUse" + x="6" + y="20" + width="20" + height="19" + > + <path d="M25.9149 20.0948H6.5636V38.676H25.9149V20.0948Z" fill="white" /> + </mask> + <g mask="url(#mask1_299_6811)"> + <rect + x="6.51373" + y="20.0515" + width="19.4266" + height="18.6499" + fill="url(#pattern0_299_6811)" + /> + </g> + </g> + <mask + id="mask2_299_6811" + style="mask-type: luminance" + maskUnits="userSpaceOnUse" + x="0" + y="0" + width="26" + height="25" + > + <path + d="M25.2049 5.20616C25.2049 7.96506 22.9683 10.2423 20.131 10.3975C15.0996 10.3975 10.9829 14.2869 10.8536 19.1005C10.8479 19.1828 10.8479 19.261 10.8479 19.3433C10.6917 22.0038 8.42956 24.1003 5.65615 24.2136H5.63485C5.56527 24.219 5.49001 24.219 5.42042 24.219C5.12505 24.219 4.83535 24.1987 4.54992 24.1461C4.46329 24.1313 4.37809 24.1151 4.29146 24.1003C4.03869 24.0544 3.79728 23.9869 3.56013 23.9046C3.25907 23.7967 2.97364 23.6672 2.69956 23.5121C2.61294 23.4662 2.53341 23.419 2.45815 23.3677C2.43685 23.3569 2.41555 23.3421 2.39425 23.3259C2.28632 23.2584 2.18407 23.1869 2.08751 23.1141C2.07189 23.1033 2.05485 23.0938 2.03923 23.0722C1.91568 22.9792 1.79781 22.8807 1.68421 22.7781C1.61462 22.7107 1.54504 22.6432 1.47404 22.5717C1.23688 22.3397 1.03381 22.0861 0.856305 21.8122C0.813702 21.7447 0.76968 21.6732 0.727078 21.6058C0.597851 21.3994 0.485665 21.1768 0.393361 20.9501C0.296795 20.7235 0.215851 20.4847 0.156208 20.2418C0.134907 20.1595 0.113606 20.0813 0.102245 19.999C0.0866245 19.9167 0.0695836 19.8331 0.0596431 19.7508C0.0326617 19.5848 0.0170409 19.4148 0.0056803 19.2449C0 19.168 0 19.0897 0 19.0128C0 18.9049 0.0056803 18.801 0.0113606 18.6971C0.0227212 18.5474 0.038342 18.3976 0.0596431 18.2479C0.0752639 18.1292 0.0979851 18.0104 0.123546 17.8917C0.129227 17.8809 0.129227 17.8715 0.134907 17.8661C0.156208 17.7474 0.18887 17.6286 0.225792 17.5153C0.247093 17.4425 0.274074 17.3656 0.301056 17.2927C0.328037 17.2158 0.360699 17.1322 0.39194 17.0553C0.445903 16.9258 0.505546 16.7976 0.56945 16.6735C0.617732 16.575 0.671695 16.4819 0.725658 16.3942C0.816543 16.2391 0.918788 16.0893 1.02671 15.945C1.11334 15.8262 1.20422 15.7129 1.30079 15.6036C1.30647 15.5983 1.31215 15.5929 1.31641 15.5888C1.37605 15.5214 1.43427 15.4593 1.4996 15.3972C1.54788 15.346 1.59616 15.2988 1.64445 15.2529C1.71971 15.18 1.79497 15.1139 1.87592 15.0519C1.9455 14.9898 2.01509 14.9331 2.09035 14.8819C2.26218 14.747 2.44537 14.6283 2.63282 14.5203C2.75068 14.4529 2.86997 14.3908 2.99352 14.3341C3.0631 14.2923 3.13836 14.2613 3.21363 14.2303C3.26759 14.2046 3.32155 14.1844 3.37978 14.1628C3.43374 14.1372 3.49196 14.1169 3.55161 14.1008C4.02449 13.9308 4.53004 13.8269 5.05688 13.8013H5.12647C5.28836 13.8013 5.44882 13.7959 5.60503 13.7864H5.62065C10.5014 13.5072 14.3811 9.61771 14.3811 4.89722C14.5458 2.15451 16.9159 0 19.7859 0C22.6559 0 25.2035 2.33933 25.2035 5.20616H25.2049Z" + fill="white" + /> + </mask> + <g mask="url(#mask2_299_6811)"> + <mask + id="mask3_299_6811" + style="mask-type: luminance" + maskUnits="userSpaceOnUse" + x="-1" + y="-1" + width="27" + height="26" + > + <path d="M25.9148 -0.674683H-0.708801V24.8947H25.9148V-0.674683Z" fill="white" /> + </mask> + <g mask="url(#mask3_299_6811)"> + <rect + x="-0.711609" + y="-0.735229" + width="26.652" + height="25.6436" + fill="url(#pattern1_299_6811)" + /> + </g> + </g> + <g clip-path="url(#clip1_299_6811)"> + <path + d="M48.8011 15.0003C45.3904 15.0003 42.8572 12.6484 42.8572 9.17837C42.8572 5.70834 45.3904 3.44885 48.8011 3.44885C52.2118 3.44885 54.7615 5.66216 54.7615 9.17837C54.7615 12.6946 52.2283 15.0003 48.8011 15.0003ZM48.8011 5.83368C46.9473 5.83368 45.6807 7.25864 45.6807 9.17837C45.6807 11.0981 46.9638 12.5857 48.8011 12.5857C50.6384 12.5857 51.9347 11.1608 51.9347 9.17837C51.9347 7.19597 50.6516 5.83368 48.8011 5.83368Z" + fill="url(#paint1_linear_299_6811)" + /> + <path + d="M57.928 14.7063V12.4139L63.1298 5.98835H58.0501V3.74207H66.4349V5.86301L61.1078 12.4139H66.5438V14.7063H57.928Z" + fill="#001E49" + /> + <path + d="M76.0732 14.9869C72.5834 14.9869 70.0666 12.6648 70.0666 9.21124C70.0666 5.75769 72.6626 3.44873 75.9974 3.44873C77.7093 3.44873 79.3025 4.02267 80.2129 4.90338L78.5438 6.80662C78.0358 6.21948 77.1551 5.78408 76.0897 5.78408C74.2359 5.78408 72.877 7.23872 72.877 9.20464C72.877 11.1706 74.0644 12.6582 76.2118 12.6582C76.8286 12.6582 77.3992 12.5658 77.8643 12.3646V10.3657H75.7796V8.22828H80.2887V14.0337C79.2233 14.5911 77.7555 14.977 76.0732 14.977V14.9869Z" + fill="#001E49" + /> + <path d="M84.1975 11.6554V9.64331H88.1194V11.6554H84.1975Z" fill="#001E49" /> + <path + d="M101.554 13.0804C100.644 14.258 99.4069 15.0001 97.5697 15.0001C94.281 15.0001 91.9325 12.5691 91.9325 9.22443C91.9325 5.87974 94.3272 3.44873 97.5829 3.44873C99.1892 3.44873 100.565 4.00618 101.35 5.04521L100.716 5.63235C100.083 4.82751 98.9418 4.23708 97.596 4.23708C94.6769 4.23708 92.8561 6.49656 92.8561 9.22443C92.8561 11.9523 94.6769 14.2118 97.596 14.2118C98.8792 14.2118 99.9578 13.8259 100.947 12.6021L101.551 13.0804H101.554Z" + fill="#001E49" + /> + <path d="M106.743 14.7064H105.908V3H106.743V14.7064Z" fill="#001E49" /> + <path + d="M115.22 14.9075C112.997 14.9075 111.437 13.2814 111.437 11.0977C111.437 8.91412 112.997 7.30444 115.22 7.30444C117.443 7.30444 119.02 8.91412 119.02 11.0977C119.02 13.2814 117.46 14.9075 115.22 14.9075ZM115.22 8.03342C113.429 8.03342 112.301 9.37921 112.301 11.0977C112.301 12.8163 113.429 14.1489 115.22 14.1489C117.011 14.1489 118.156 12.8328 118.156 11.0977C118.156 9.36272 117.028 8.03342 115.22 8.03342Z" + fill="#001E49" + /> + <path + d="M128.965 14.7064C128.919 14.3205 128.902 13.6542 128.902 13.2815H128.873C128.457 14.271 127.405 14.9076 126.31 14.9076C124.41 14.9076 123.608 13.5915 123.608 11.9027V7.48926H124.456V11.5761C124.456 13.0011 124.888 14.1918 126.494 14.1918C127.731 14.1918 128.873 13.1693 128.873 11.1572V7.48596H129.707V13.044C129.707 13.4629 129.737 14.2215 129.786 14.6998H128.968L128.965 14.7064Z" + fill="#001E49" + /> + <path + d="M140.932 13.2815C140.315 14.3337 139.187 14.9076 138.059 14.9076C135.865 14.9076 134.384 13.2518 134.384 11.0979C134.384 8.94392 135.869 7.30456 138.059 7.30456C139.187 7.30456 140.315 7.86201 140.932 8.96042H140.965V3H141.813V14.7064H140.965V13.2815H140.932ZM138.138 14.1787C139.837 14.1787 141.057 12.8329 141.057 11.0979C141.057 9.36284 139.837 8.00055 138.138 8.00055C136.33 8.00055 135.265 9.40902 135.265 11.0979C135.265 12.7867 136.33 14.1787 138.138 14.1787Z" + fill="#001E49" + /> + <path + d="M47.0056 25.2249L44.1814 33.0002H43.0271L46.2791 24.469H47.0232L47.0056 25.2249ZM49.3728 33.0002L46.5427 25.2249L46.5251 24.469H47.2693L50.533 33.0002H49.3728ZM49.2263 29.842V30.7678H44.4333V29.842H49.2263ZM55.5076 31.7698V24.0002H56.5974V33.0002H55.6013L55.5076 31.7698ZM51.2419 29.9006V29.7776C51.2419 29.2932 51.3005 28.8538 51.4177 28.4592C51.5388 28.0608 51.7087 27.719 51.9275 27.4338C52.1501 27.1487 52.4138 26.9299 52.7185 26.7776C53.0271 26.6213 53.3708 26.5432 53.7498 26.5432C54.1482 26.5432 54.4958 26.6135 54.7927 26.7542C55.0935 26.8909 55.3474 27.092 55.5544 27.3577C55.7654 27.6194 55.9314 27.9358 56.0525 28.3069C56.1736 28.678 56.2576 29.0979 56.3044 29.5667V30.1057C56.2615 30.5706 56.1775 30.9885 56.0525 31.3596C55.9314 31.7307 55.7654 32.0471 55.5544 32.3088C55.3474 32.5706 55.0935 32.7717 54.7927 32.9124C54.4919 33.0491 54.1404 33.1174 53.738 33.1174C53.3669 33.1174 53.0271 33.0374 52.7185 32.8772C52.4138 32.717 52.1501 32.4924 51.9275 32.2034C51.7087 31.9143 51.5388 31.5745 51.4177 31.1838C51.3005 30.7893 51.2419 30.3616 51.2419 29.9006ZM52.3318 29.7776V29.9006C52.3318 30.217 52.363 30.5139 52.4255 30.7913C52.4919 31.0686 52.5935 31.3127 52.7302 31.5237C52.8669 31.7346 53.0408 31.9006 53.2517 32.0217C53.4626 32.1389 53.7146 32.1975 54.0076 32.1975C54.3669 32.1975 54.6619 32.1213 54.8923 31.969C55.1267 31.8167 55.3142 31.6155 55.4548 31.3655C55.5955 31.1155 55.7048 30.844 55.783 30.551V29.1389C55.7361 28.9241 55.6677 28.717 55.5779 28.5178C55.4919 28.3147 55.3787 28.135 55.238 27.9788C55.1013 27.8186 54.9314 27.6917 54.7283 27.5979C54.5291 27.5042 54.2927 27.4573 54.0193 27.4573C53.7224 27.4573 53.4666 27.5198 53.2517 27.6448C53.0408 27.7659 52.8669 27.9338 52.7302 28.1487C52.5935 28.3596 52.4919 28.6057 52.4255 28.887C52.363 29.1643 52.3318 29.4612 52.3318 29.7776ZM59.363 27.9202V33.0002H58.2732V26.6604H59.3044L59.363 27.9202ZM59.1404 29.5901L58.6365 29.5725C58.6404 29.1389 58.697 28.7385 58.8064 28.3713C58.9158 28.0002 59.0779 27.678 59.2927 27.4045C59.5076 27.1311 59.7751 26.9202 60.0955 26.7717C60.4158 26.6194 60.7869 26.5432 61.2087 26.5432C61.5056 26.5432 61.7791 26.5862 62.0291 26.6721C62.2791 26.7542 62.4958 26.885 62.6794 27.0647C62.863 27.2444 63.0056 27.4749 63.1072 27.7561C63.2087 28.0374 63.2595 28.3772 63.2595 28.7756V33.0002H62.1755V28.8284C62.1755 28.4963 62.1189 28.2307 62.0056 28.0315C61.8962 27.8323 61.74 27.6877 61.5369 27.5979C61.3337 27.5042 61.0955 27.4573 60.822 27.4573C60.5017 27.4573 60.2341 27.5139 60.0193 27.6272C59.8044 27.7405 59.6326 27.8967 59.5037 28.0959C59.3748 28.2952 59.281 28.5237 59.2224 28.7815C59.1677 29.0354 59.1404 29.3049 59.1404 29.5901ZM63.2478 28.9924L62.5212 29.2151C62.5251 28.8674 62.5818 28.5334 62.6912 28.2131C62.8044 27.8928 62.9666 27.6077 63.1775 27.3577C63.3923 27.1077 63.656 26.9104 63.9685 26.7659C64.281 26.6174 64.6384 26.5432 65.0408 26.5432C65.3806 26.5432 65.6814 26.5881 65.9431 26.678C66.2087 26.7678 66.4314 26.9065 66.6111 27.094C66.7947 27.2776 66.9333 27.5139 67.0271 27.803C67.1208 28.092 67.1677 28.4358 67.1677 28.8342V33.0002H66.0779V28.8225C66.0779 28.467 66.0212 28.1917 65.908 27.9963C65.7986 27.7971 65.6423 27.6584 65.4392 27.5803C65.24 27.4983 65.0017 27.4573 64.7244 27.4573C64.4861 27.4573 64.2751 27.4983 64.0916 27.5803C63.908 27.6624 63.7537 27.7756 63.6287 27.9202C63.5037 28.0608 63.408 28.2229 63.3416 28.4065C63.2791 28.5901 63.2478 28.7854 63.2478 28.9924ZM69.9861 26.6604V33.0002H68.8962V26.6604H69.9861ZM68.8142 24.9788C68.8142 24.803 68.8669 24.6545 68.9724 24.5334C69.0818 24.4124 69.2419 24.3518 69.4529 24.3518C69.6599 24.3518 69.8181 24.4124 69.9275 24.5334C70.0408 24.6545 70.0974 24.803 70.0974 24.9788C70.0974 25.1467 70.0408 25.2913 69.9275 25.4124C69.8181 25.5295 69.6599 25.5881 69.4529 25.5881C69.2419 25.5881 69.0818 25.5295 68.9724 25.4124C68.8669 25.2913 68.8142 25.1467 68.8142 24.9788ZM72.8103 28.0139V33.0002H71.7263V26.6604H72.7517L72.8103 28.0139ZM72.5525 29.5901L72.1013 29.5725C72.1052 29.1389 72.1697 28.7385 72.2947 28.3713C72.4197 28.0002 72.5955 27.678 72.822 27.4045C73.0486 27.1311 73.3181 26.9202 73.6306 26.7717C73.947 26.6194 74.2966 26.5432 74.6794 26.5432C74.9919 26.5432 75.2732 26.5862 75.5232 26.6721C75.7732 26.7542 75.9861 26.887 76.1619 27.0706C76.3416 27.2542 76.4783 27.4924 76.572 27.7854C76.6658 28.0745 76.7126 28.428 76.7126 28.8459V33.0002H75.6228V28.8342C75.6228 28.5022 75.574 28.2366 75.4763 28.0374C75.3787 27.8342 75.2361 27.6877 75.0486 27.5979C74.8611 27.5042 74.6306 27.4573 74.3572 27.4573C74.0876 27.4573 73.8416 27.5139 73.6189 27.6272C73.4001 27.7405 73.2107 27.8967 73.0505 28.0959C72.8943 28.2952 72.7712 28.5237 72.6814 28.7815C72.5955 29.0354 72.5525 29.3049 72.5525 29.5901ZM79.5369 26.6604V33.0002H78.447V26.6604H79.5369ZM78.365 24.9788C78.365 24.803 78.4177 24.6545 78.5232 24.5334C78.6326 24.4124 78.7927 24.3518 79.0037 24.3518C79.2107 24.3518 79.3689 24.4124 79.4783 24.5334C79.5916 24.6545 79.6482 24.803 79.6482 24.9788C79.6482 25.1467 79.5916 25.2913 79.4783 25.4124C79.3689 25.5295 79.2107 25.5881 79.0037 25.5881C78.7927 25.5881 78.6326 25.5295 78.5232 25.4124C78.4177 25.2913 78.365 25.1467 78.365 24.9788ZM84.9685 31.3186C84.9685 31.1624 84.9333 31.0178 84.863 30.885C84.7966 30.7483 84.658 30.6252 84.447 30.5159C84.24 30.4026 83.9275 30.3049 83.5095 30.2229C83.158 30.1487 82.8396 30.0608 82.5544 29.9592C82.2732 29.8577 82.033 29.7346 81.8337 29.5901C81.6384 29.4456 81.488 29.2756 81.3826 29.0803C81.2771 28.885 81.2244 28.6565 81.2244 28.3948C81.2244 28.1448 81.2791 27.9084 81.3884 27.6858C81.5017 27.4631 81.6599 27.2659 81.863 27.094C82.0701 26.9221 82.3181 26.7874 82.6072 26.6897C82.8962 26.592 83.2185 26.5432 83.574 26.5432C84.0818 26.5432 84.5154 26.6331 84.8748 26.8127C85.2341 26.9924 85.5095 27.2327 85.7009 27.5334C85.8923 27.8303 85.988 28.1604 85.988 28.5237H84.9041C84.9041 28.3479 84.8513 28.178 84.7458 28.0139C84.6443 27.8459 84.4939 27.7073 84.2947 27.5979C84.0994 27.4885 83.8591 27.4338 83.574 27.4338C83.2732 27.4338 83.0291 27.4807 82.8416 27.5745C82.658 27.6643 82.5232 27.7795 82.4373 27.9202C82.3552 28.0608 82.3142 28.2092 82.3142 28.3655C82.3142 28.4827 82.3337 28.5881 82.3728 28.6819C82.4158 28.7717 82.49 28.8557 82.5955 28.9338C82.7009 29.0081 82.8494 29.0784 83.0408 29.1448C83.2322 29.2112 83.4763 29.2776 83.7732 29.344C84.2927 29.4612 84.7205 29.6018 85.0564 29.7659C85.3923 29.9299 85.6423 30.1311 85.8064 30.3694C85.9705 30.6077 86.0525 30.8967 86.0525 31.2366C86.0525 31.5139 85.9939 31.7678 85.8767 31.9983C85.7634 32.2288 85.5974 32.428 85.3787 32.5959C85.1638 32.76 84.906 32.8889 84.6052 32.9827C84.3083 33.0725 83.9744 33.1174 83.6033 33.1174C83.0447 33.1174 82.572 33.0178 82.1853 32.8186C81.7986 32.6194 81.5056 32.3616 81.3064 32.0452C81.1072 31.7288 81.0076 31.3948 81.0076 31.0432H82.0974C82.113 31.3401 82.199 31.5764 82.3552 31.7522C82.5115 31.9241 82.7029 32.0471 82.9294 32.1213C83.156 32.1917 83.3806 32.2268 83.6033 32.2268C83.9001 32.2268 84.1482 32.1877 84.3474 32.1096C84.5505 32.0315 84.7048 31.9241 84.8103 31.7874C84.9158 31.6506 84.9685 31.4944 84.9685 31.3186ZM90.1306 26.6604V27.4924H86.7029V26.6604H90.1306ZM87.863 25.1194H88.947V31.4299C88.947 31.6448 88.9802 31.8069 89.0466 31.9163C89.113 32.0256 89.199 32.0979 89.3044 32.1331C89.4099 32.1682 89.5232 32.1858 89.6443 32.1858C89.7341 32.1858 89.8279 32.178 89.9255 32.1624C90.0271 32.1428 90.1033 32.1272 90.1541 32.1155L90.1599 33.0002C90.074 33.0276 89.9607 33.053 89.8201 33.0764C89.6833 33.1038 89.5173 33.1174 89.322 33.1174C89.0564 33.1174 88.8123 33.0647 88.5896 32.9592C88.3669 32.8538 88.1892 32.678 88.0564 32.4319C87.9275 32.1819 87.863 31.8459 87.863 31.4241V25.1194ZM92.4861 27.6565V33.0002H91.4021V26.6604H92.4568L92.4861 27.6565ZM94.4666 26.6252L94.4607 27.6331C94.3708 27.6135 94.2849 27.6018 94.2029 27.5979C94.1248 27.5901 94.0349 27.5862 93.9333 27.5862C93.6833 27.5862 93.4626 27.6252 93.2712 27.7034C93.0798 27.7815 92.9177 27.8909 92.7849 28.0315C92.6521 28.1721 92.5466 28.3401 92.4685 28.5354C92.3943 28.7268 92.3455 28.9377 92.322 29.1682L92.0173 29.344C92.0173 28.9612 92.0544 28.6018 92.1287 28.2659C92.2068 27.9299 92.3259 27.6331 92.4861 27.3752C92.6462 27.1135 92.8494 26.9104 93.0955 26.7659C93.3455 26.6174 93.6423 26.5432 93.9861 26.5432C94.0642 26.5432 94.1541 26.553 94.2556 26.5725C94.3572 26.5881 94.4275 26.6057 94.4666 26.6252ZM98.9724 31.9163V28.6526C98.9724 28.4026 98.9216 28.1858 98.8201 28.0022C98.7224 27.8147 98.574 27.6702 98.3748 27.5686C98.1755 27.467 97.9294 27.4163 97.6365 27.4163C97.363 27.4163 97.1228 27.4631 96.9158 27.5569C96.7126 27.6506 96.5525 27.7737 96.4353 27.926C96.322 28.0784 96.2654 28.2424 96.2654 28.4182H95.1814C95.1814 28.1917 95.24 27.967 95.3572 27.7444C95.4744 27.5217 95.6423 27.3206 95.8611 27.1409C96.0837 26.9573 96.3494 26.8127 96.658 26.7073C96.9705 26.5979 97.3181 26.5432 97.7009 26.5432C98.1619 26.5432 98.5681 26.6213 98.9197 26.7776C99.2751 26.9338 99.5525 27.1702 99.7517 27.4866C99.9548 27.7991 100.056 28.1917 100.056 28.6643V31.6174C100.056 31.8284 100.074 32.053 100.109 32.2913C100.148 32.5295 100.205 32.7346 100.279 32.9065V33.0002H99.1482C99.0935 32.8752 99.0505 32.7092 99.0193 32.5022C98.988 32.2913 98.9724 32.0959 98.9724 31.9163ZM99.1599 29.1565L99.1716 29.9182H98.0759C97.7673 29.9182 97.4919 29.9436 97.2498 29.9944C97.0076 30.0413 96.8044 30.1135 96.6404 30.2112C96.4763 30.3088 96.3513 30.4319 96.2654 30.5803C96.1794 30.7249 96.1365 30.8948 96.1365 31.0901C96.1365 31.2893 96.1814 31.4709 96.2712 31.635C96.3611 31.7991 96.4958 31.9299 96.6755 32.0276C96.8591 32.1213 97.0837 32.1682 97.3494 32.1682C97.6814 32.1682 97.9744 32.0979 98.2283 31.9573C98.4822 31.8167 98.6833 31.6448 98.8318 31.4417C98.9841 31.2385 99.0662 31.0413 99.0779 30.8499L99.5408 31.3713C99.5134 31.5354 99.4392 31.717 99.3181 31.9163C99.197 32.1155 99.0349 32.3069 98.8318 32.4905C98.6326 32.6702 98.3943 32.8206 98.1169 32.9417C97.8435 33.0588 97.5349 33.1174 97.1912 33.1174C96.7615 33.1174 96.3845 33.0334 96.0603 32.8655C95.74 32.6975 95.49 32.4729 95.3103 32.1917C95.1345 31.9065 95.0466 31.5881 95.0466 31.2366C95.0466 30.8967 95.113 30.5979 95.2458 30.3401C95.3787 30.0784 95.5701 29.8616 95.8201 29.6897C96.0701 29.5139 96.3708 29.3811 96.7224 29.2913C97.074 29.2014 97.4666 29.1565 97.9001 29.1565H99.1599ZM104.416 26.6604V27.4924H100.988V26.6604H104.416ZM102.148 25.1194H103.232V31.4299C103.232 31.6448 103.265 31.8069 103.332 31.9163C103.398 32.0256 103.484 32.0979 103.59 32.1331C103.695 32.1682 103.808 32.1858 103.929 32.1858C104.019 32.1858 104.113 32.178 104.211 32.1624C104.312 32.1428 104.388 32.1272 104.439 32.1155L104.445 33.0002C104.359 33.0276 104.246 33.053 104.105 33.0764C103.969 33.1038 103.802 33.1174 103.607 33.1174C103.342 33.1174 103.097 33.0647 102.875 32.9592C102.652 32.8538 102.474 32.678 102.342 32.4319C102.213 32.1819 102.148 31.8459 102.148 31.4241V25.1194ZM106.865 26.6604V33.0002H105.775V26.6604H106.865ZM105.693 24.9788C105.693 24.803 105.746 24.6545 105.851 24.5334C105.961 24.4124 106.121 24.3518 106.332 24.3518C106.539 24.3518 106.697 24.4124 106.806 24.5334C106.92 24.6545 106.976 24.803 106.976 24.9788C106.976 25.1467 106.92 25.2913 106.806 25.4124C106.697 25.5295 106.539 25.5881 106.332 25.5881C106.121 25.5881 105.961 25.5295 105.851 25.4124C105.746 25.2913 105.693 25.1467 105.693 24.9788ZM108.318 29.9006V29.7659C108.318 29.3088 108.385 28.885 108.517 28.4944C108.65 28.0999 108.842 27.7581 109.092 27.469C109.342 27.176 109.644 26.9495 110 26.7893C110.355 26.6252 110.754 26.5432 111.195 26.5432C111.64 26.5432 112.041 26.6252 112.396 26.7893C112.756 26.9495 113.06 27.176 113.31 27.469C113.564 27.7581 113.758 28.0999 113.89 28.4944C114.023 28.885 114.09 29.3088 114.09 29.7659V29.9006C114.09 30.3577 114.023 30.7815 113.89 31.1721C113.758 31.5627 113.564 31.9045 113.31 32.1975C113.06 32.4866 112.758 32.7131 112.402 32.8772C112.051 33.0374 111.652 33.1174 111.207 33.1174C110.761 33.1174 110.361 33.0374 110.006 32.8772C109.65 32.7131 109.345 32.4866 109.092 32.1975C108.842 31.9045 108.65 31.5627 108.517 31.1721C108.385 30.7815 108.318 30.3577 108.318 29.9006ZM109.402 29.7659V29.9006C109.402 30.217 109.439 30.5159 109.513 30.7971C109.588 31.0745 109.699 31.3206 109.847 31.5354C110 31.7502 110.189 31.9202 110.416 32.0452C110.642 32.1663 110.906 32.2268 111.207 32.2268C111.504 32.2268 111.763 32.1663 111.986 32.0452C112.213 31.9202 112.4 31.7502 112.549 31.5354C112.697 31.3206 112.808 31.0745 112.883 30.7971C112.961 30.5159 113 30.217 113 29.9006V29.7659C113 29.4534 112.961 29.1584 112.883 28.8811C112.808 28.5999 112.695 28.3518 112.543 28.137C112.394 27.9182 112.207 27.7463 111.98 27.6213C111.758 27.4963 111.496 27.4338 111.195 27.4338C110.898 27.4338 110.636 27.4963 110.41 27.6213C110.187 27.7463 110 27.9182 109.847 28.137C109.699 28.3518 109.588 28.5999 109.513 28.8811C109.439 29.1584 109.402 29.4534 109.402 29.7659ZM116.533 28.0139V33.0002H115.449V26.6604H116.474L116.533 28.0139ZM116.275 29.5901L115.824 29.5725C115.828 29.1389 115.892 28.7385 116.017 28.3713C116.142 28.0002 116.318 27.678 116.545 27.4045C116.771 27.1311 117.041 26.9202 117.353 26.7717C117.67 26.6194 118.019 26.5432 118.402 26.5432C118.715 26.5432 118.996 26.5862 119.246 26.6721C119.496 26.7542 119.709 26.887 119.885 27.0706C120.064 27.2542 120.201 27.4924 120.295 27.7854C120.388 28.0745 120.435 28.428 120.435 28.8459V33.0002H119.345V28.8342C119.345 28.5022 119.297 28.2366 119.199 28.0374C119.101 27.8342 118.959 27.6877 118.771 27.5979C118.584 27.5042 118.353 27.4573 118.08 27.4573C117.81 27.4573 117.564 27.5139 117.342 27.6272C117.123 27.7405 116.933 27.8967 116.773 28.0959C116.617 28.2952 116.494 28.5237 116.404 28.7815C116.318 29.0354 116.275 29.3049 116.275 29.5901Z" + fill="#001E49" + /> + </g> + </g> + <defs> + <pattern id="pattern0_299_6811" patternContentUnits="objectBoundingBox" width="1" height="1"> + <use xlink:href="#image0_299_6811" transform="scale(0.00352113 0.00348432)" /> + </pattern> + <pattern id="pattern1_299_6811" patternContentUnits="objectBoundingBox" width="1" height="1"> + <use xlink:href="#image1_299_6811" transform="scale(0.00255754 0.00253165)" /> + </pattern> + <linearGradient + id="paint0_linear_299_6811" + x1="20.483" + y1="22.8118" + x2="34.857" + y2="22.8118" + gradientUnits="userSpaceOnUse" + > + <stop stop-color="#003064" /> + <stop offset="1" stop-color="#D4004B" /> + </linearGradient> + <linearGradient + id="paint1_linear_299_6811" + x1="42.8572" + y1="9.22455" + x2="54.7615" + y2="9.22455" + gradientUnits="userSpaceOnUse" + > + <stop stop-color="#003064" /> + <stop offset="1" stop-color="#D4004B" /> + </linearGradient> + <clipPath id="clip0_299_6811"> + <rect width="142.857" height="38" fill="white" /> + </clipPath> + <clipPath id="clip1_299_6811"> + <rect x="42.8572" y="1" width="100" height="36" rx="8" fill="white" /> + </clipPath> + <image + id="image0_299_6811" + width="284" + height="287" + xlink:href="data:image/png;base64,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" + /> + <image + id="image1_299_6811" + width="391" + height="395" + xlink:href="data:image/png;base64,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" + /> + </defs> +</svg> diff --git a/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f1a5e669c89bb2a011dad68d186f9b78bb90538 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminLogoIconComponent } from './admin-logo-icon.component'; + +describe('AdminLogoIconComponent', () => { + let component: AdminLogoIconComponent; + let fixture: ComponentFixture<AdminLogoIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminLogoIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminLogoIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..671a0c8229a6642a0724cd49d4f1eb6803bbd913 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.component.ts @@ -0,0 +1,10 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ods-admin-logo-icon', + standalone: true, + imports: [CommonModule], + templateUrl: `./admin-logo-icon.component.html`, +}) +export class AdminLogoIconComponent {} diff --git a/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..f78a122377169b2458f0e20f8e90804e31082330 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/admin-logo-icon/admin-logo-icon.stories.ts @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { AdminLogoIconComponent } from './admin-logo-icon.component'; + +const meta: Meta<AdminLogoIconComponent> = { + title: 'Icons/Admin logo icon', + component: AdminLogoIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<AdminLogoIconComponent>; + +export const Default: Story = {}; diff --git a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts index 5de688d73e98d67b1296d1519332fd7e3b7c9660..48fe0de3b897881d8bb669774572ea8f68a6895e 100644 --- a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts +++ b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts @@ -12,16 +12,16 @@ import { IconVariants, iconVariants } from '../iconVariants'; xmlns="http://www.w3.org/2000/svg" [ngClass]="[twMerge(iconVariants({ size }), 'fill-black', class)]" aria-hidden="true" - viewBox="0 0 14 14" + viewBox="0 0 24 24" fill="inherit" > <path - d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z" + d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z" /> </svg>`, }) export class CloseIconComponent { - @Input() size: IconVariants['size'] = 'small'; + @Input() size: IconVariants['size'] = 'medium'; @Input() class: string = undefined; iconVariants = iconVariants; diff --git a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts index b8a8eda0b8afab576ba5b7086d226aa2f0adcc96..9cd750050413bd481bcdc246810c8eeb24b53c8c 100644 --- a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts +++ b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts @@ -20,7 +20,7 @@ export const Default: Story = { options: ['small', 'medium', 'large', 'extra-large', 'full'], description: 'Size of icon. Property "full" means 100%', table: { - defaultValue: { summary: 'small' }, + defaultValue: { summary: 'medium' }, }, }, }, diff --git a/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1a2136a653c2b9dfc52bc039fb0aa1d48ce0733 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CollaborationIconComponent } from './collaboration-icon.component'; + +describe('CollaborationIconComponent', () => { + let component: CollaborationIconComponent; + let fixture: ComponentFixture<CollaborationIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollaborationIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CollaborationIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8565574eac373499fcedaea5b514e7f616b344e1 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.component.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; +import { IconVariants, iconVariants } from '../iconVariants'; + +@Component({ + selector: 'ods-collaboration-icon', + standalone: true, + imports: [CommonModule], + template: `<svg + viewBox="0 0 24 24" + [ngClass]="[twMerge(iconVariants({ size }), 'stroke-primary', class)]" + aria-hidden="true" + fill="inherit" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M16 21V19C16 17.9391 15.5786 16.9217 14.8284 16.1716C14.0783 15.4214 13.0609 15 12 15H6C4.93913 15 3.92172 15.4214 3.17157 16.1716C2.42143 16.9217 2 17.9391 2 19V21" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + fill="none" + /> + <path + d="M9 11C11.2091 11 13 9.20914 13 7C13 4.79086 11.2091 3 9 3C6.79086 3 5 4.79086 5 7C5 9.20914 6.79086 11 9 11Z" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + fill="none" + /> + <path + d="M22 20.9999V18.9999C21.9993 18.1136 21.7044 17.2527 21.1614 16.5522C20.6184 15.8517 19.8581 15.3515 19 15.1299" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + fill="none" + /> + <path + d="M16 3.12988C16.8604 3.35018 17.623 3.85058 18.1676 4.55219C18.7122 5.2538 19.0078 6.11671 19.0078 7.00488C19.0078 7.89305 18.7122 8.75596 18.1676 9.45757C17.623 10.1592 16.8604 10.6596 16 10.8799" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + fill="none" + /> + </svg>`, +}) +export class CollaborationIconComponent { + @Input() size: IconVariants['size'] = 'medium'; + @Input() class: string = undefined; + + iconVariants = iconVariants; + twMerge = twMerge; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbc1440939f920bb66062062c12ac343a347ddce --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/collaboration-icon/collaboration-icon.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { CollaborationIconComponent } from './collaboration-icon.component'; + +const meta: Meta<CollaborationIconComponent> = { + title: 'Icons/Collaboration icon', + component: CollaborationIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<CollaborationIconComponent>; + +export const Default: Story = { + args: { size: 'medium' }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large', 'extra-large', 'full'], + description: 'Size of icon. Property "full" means 100%', + table: { + defaultValue: { summary: 'medium' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/icons/iconVariants.ts b/alfa-client/libs/design-system/src/lib/icons/iconVariants.ts index 6ea2dff309ce50e62416e5dd898bcc4771563625..e13cd3b4f155db048c8aab75887fa4f8f13172da 100644 --- a/alfa-client/libs/design-system/src/lib/icons/iconVariants.ts +++ b/alfa-client/libs/design-system/src/lib/icons/iconVariants.ts @@ -8,6 +8,7 @@ export const iconVariants = cva('', { medium: 'size-6', large: 'size-8', 'extra-large': 'size-10', + xxl: 'size-12', }, }, }); diff --git a/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..480de053d282d568be7651299ff74244c77f64c4 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LogoutIconComponent } from './logout-icon.component'; + +describe('LogoutIconComponent', () => { + let component: LogoutIconComponent; + let fixture: ComponentFixture<LogoutIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LogoutIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LogoutIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..030b3a9502b7f12793f40bc6bbe5d2a740804d47 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.component.ts @@ -0,0 +1,27 @@ +import { NgClass } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; + +import { IconVariants, iconVariants } from '../iconVariants'; + +@Component({ + selector: 'ods-logout-icon', + standalone: true, + imports: [NgClass], + template: `<svg + [ngClass]="twMerge(iconVariants({ size }), 'fill-text', class)" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M5 21C4.45 21 3.97917 20.8042 3.5875 20.4125C3.19583 20.0208 3 19.55 3 19V5C3 4.45 3.19583 3.97917 3.5875 3.5875C3.97917 3.19583 4.45 3 5 3H12V5H5V19H12V21H5ZM16 17L14.625 15.55L17.175 13H9V11H17.175L14.625 8.45L16 7L21 12L16 17Z" + /> + </svg>`, +}) +export class LogoutIconComponent { + @Input() size: IconVariants['size'] = 'medium'; + @Input() class: string = ''; + + readonly iconVariants = iconVariants; + readonly twMerge = twMerge; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..f09a093d0502d2142acfeeb343db9dc90db2b149 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/logout-icon/logout-icon.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { LogoutIconComponent } from './logout-icon.component'; + +const meta: Meta<LogoutIconComponent> = { + title: 'Icons/Logout icon', + component: LogoutIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<LogoutIconComponent>; + +export const Default: Story = { + args: { size: 'medium' }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large', 'extra-large', 'full'], + description: 'Size of icon. Property "full" means 100%', + table: { + defaultValue: { summary: 'medium' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b41f00f7375d9ef6362471bda4734fa271f18ead --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OfficeIconComponent } from './office-icon.component'; + +describe('SaveIconComponent', () => { + let component: OfficeIconComponent; + let fixture: ComponentFixture<OfficeIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OfficeIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(OfficeIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..25bdbb57d5011b3135a55be56cae8121c1611485 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.component.ts @@ -0,0 +1,28 @@ +import { NgClass } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; + +import { IconVariants, iconVariants } from '../iconVariants'; + +@Component({ + selector: 'ods-office-icon', + standalone: true, + imports: [NgClass], + template: `<svg + [ngClass]="twMerge(iconVariants({ size }), 'fill-black', class)" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M2 21V4.75L7 1L12 4.75V7H22V21H2ZM4 19H6V17H4V19ZM4 15H6V13H4V15ZM4 11H6V9H4V11ZM4 7H6V5H4V7ZM8 7H10V5H8V7ZM8 19H20V9H8V19ZM14 13V11H18V13H14ZM14 17V15H18V17H14ZM10 13V11H12V13H10ZM10 17V15H12V17H10Z" + /> + </svg>`, +}) +export class OfficeIconComponent { + @Input() size: IconVariants['size'] = 'medium'; + @Input() class: string = undefined; + + iconVariants = iconVariants; + twMerge = twMerge; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..46b9e413bf6ac9da265c12d440201cf12017da9e --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/office-icon/office-icon.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { OfficeIconComponent } from './office-icon.component'; + +const meta: Meta<OfficeIconComponent> = { + title: 'Icons/Office icon', + component: OfficeIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<OfficeIconComponent>; + +export const Default: Story = { + args: { size: 'large' }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large', 'extra-large', 'full'], + description: 'Size of icon. Property "full" means 100%', + table: { + defaultValue: { summary: 'medium' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5981e81db28b29d1dfe651b306e3f3f938ec0bfb --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchIconComponent } from './search-icon.component'; + +describe('SearchIconComponent', () => { + let component: SearchIconComponent; + let fixture: ComponentFixture<SearchIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..641713c3842c42cadb4d4075051fc51141d5f2e3 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts @@ -0,0 +1,28 @@ +import { NgClass } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; + +import { IconVariants, iconVariants } from '../iconVariants'; + +@Component({ + selector: 'ods-search-icon', + standalone: true, + imports: [NgClass], + template: `<svg + [ngClass]="twMerge(iconVariants({ size }), 'fill-primary', class)" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" + /> + </svg>`, +}) +export class SearchIconComponent { + @Input() size: IconVariants['size'] = 'medium'; + @Input() class: string = ''; + + iconVariants = iconVariants; + twMerge = twMerge; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..1952260187ba8fc7f462803ad2e2674f6d071856 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { SearchIconComponent } from './search-icon.component'; + +const meta: Meta<SearchIconComponent> = { + title: 'Icons/Search icon', + component: SearchIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<SearchIconComponent>; + +export const Default: Story = { + args: { size: 'large' }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large', 'extra-large', 'full'], + description: 'Size of icon. Property "full" means 100%', + table: { + defaultValue: { summary: 'medium' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e80ce433f6db593962497c6e2c92386745a5045 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserIconComponent } from './user-icon.component'; + +describe('UserIconComponent', () => { + let component: UserIconComponent; + let fixture: ComponentFixture<UserIconComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserIconComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UserIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..90c01e353f6029d459584e6fcdafa425c6e6d9be --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; +import { ExclamationIconComponent } from '../exclamation-icon/exclamation-icon.component'; +import { IconVariants, iconVariants } from '../iconVariants'; + +@Component({ + selector: 'ods-user-icon', + standalone: true, + imports: [CommonModule, ExclamationIconComponent], + template: ` + <svg + viewBox="0 0 47 47" + fill="none" + xmlns="http://www.w3.org/2000/svg" + [ngClass]="[twMerge(iconVariants({ size }), 'fill-ozggray-300', class)]" + > + <path + d="M23.5 3.91663C12.69 3.91663 3.91669 12.69 3.91669 23.5C3.91669 34.31 12.69 43.0833 23.5 43.0833C34.31 43.0833 43.0834 34.31 43.0834 23.5C43.0834 12.69 34.31 3.91663 23.5 3.91663ZM23.5 9.79163C26.7509 9.79163 29.375 12.4158 29.375 15.6666C29.375 18.9175 26.7509 21.5416 23.5 21.5416C20.2492 21.5416 17.625 18.9175 17.625 15.6666C17.625 12.4158 20.2492 9.79163 23.5 9.79163ZM23.5 37.6C18.6042 37.6 14.2763 35.0933 11.75 31.2941C11.8088 27.397 19.5834 25.2625 23.5 25.2625C27.3971 25.2625 35.1913 27.397 35.25 31.2941C32.7238 35.0933 28.3959 37.6 23.5 37.6Z" + /> + </svg> + `, +}) +export class UserIconComponent { + @Input() variant: 'user' | 'initials' = 'user'; + @Input() size: IconVariants['size'] = 'xxl'; + @Input() class: string = undefined; + + iconVariants = iconVariants; + twMerge = twMerge; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf801c263a4b51d9dc560f937de4047b07da27a2 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/user-icon/user-icon.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { UserIconComponent } from './user-icon.component'; + +const meta: Meta<UserIconComponent> = { + title: 'Icons/User icon', + component: UserIconComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<UserIconComponent>; + +export const Default: Story = { + args: { size: 'xxl' }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large', 'extra-large', 'xxl', 'full'], + description: 'Size of icon. Property "full" means 100%', + table: { + defaultValue: { summary: 'xxl' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..72cc13e7708a00114b96193a07e83a855f0c628c --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts @@ -0,0 +1,618 @@ +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import { EventEmitter } from '@angular/core'; +import { + ComponentFixture, + TestBed, + discardPeriodicTasks, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { Resource } from '@ngxp/rest'; +import { Subscription } from 'rxjs'; +import { InstantSearchComponent } from './instant-search.component'; +import { InstantSearchQuery, InstantSearchResult } from './instant-search.model'; + +describe('InstantSearchComponent', () => { + let component: InstantSearchComponent; + let fixture: ComponentFixture<InstantSearchComponent>; + + const searchResults: InstantSearchResult<Resource>[] = [ + { title: 'test', description: 'test' }, + { title: 'caption', description: 'desc' }, + ]; + const searchBy: string = 'query'; + + let searchQueryChanged: Mock<EventEmitter<any>>; + let searchResultSelected: Mock<EventEmitter<any>>; + + beforeEach(async () => { + searchQueryChanged = <any>mock(EventEmitter); + searchResultSelected = <any>mock(EventEmitter); + + await TestBed.configureTestingModule({ + imports: [InstantSearchComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstantSearchComponent); + component = fixture.componentInstance; + component.searchQueryChanged = useFromMock(searchQueryChanged); + component.searchResultSelected = useFromMock(searchResultSelected); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should handle value changes', () => { + component.handleValueChanges = jest.fn(); + + component.ngOnInit(); + + expect(component.handleValueChanges).toHaveBeenCalled(); + }); + }); + + describe('handleValueChanges', () => { + beforeEach(() => { + component.showResults = jest.fn(); + }); + + it('should subscribe to value changes', () => { + component.control.valueChanges.subscribe = jest.fn(); + + component.handleValueChanges(); + + expect(component.control.valueChanges.subscribe).toHaveBeenCalled(); + }); + + it('should emit query', fakeAsync(() => { + component.handleValueChanges(); + + component.control.setValue(searchBy); + + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS); + component.control.valueChanges.subscribe(); + + expect(searchQueryChanged.emit).toHaveBeenCalledWith({ searchBy } as InstantSearchQuery); + })); + + it('should not emit query', fakeAsync(() => { + component.handleValueChanges(); + + const searchBy: string = 'q'; + component.control.setValue(searchBy); + + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS); + component.control.valueChanges.subscribe(); + + expect(searchQueryChanged.emit).not.toHaveBeenCalled(); + })); + + describe('result are already visible', () => { + beforeEach(() => { + component.areResultsVisible = true; + }); + + it('should not show results', fakeAsync(() => { + component.handleValueChanges(); + + component.control.setValue(searchBy); + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS); + + component.control.valueChanges.subscribe(); + expect(component.showResults).not.toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + }); + + describe('results are not visible', () => { + beforeEach(() => { + component.areResultsVisible = false; + }); + + it('should show results', fakeAsync(() => { + component.handleValueChanges(); + + component.control.setValue(searchBy); + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS); + + component.control.valueChanges.subscribe(); + expect(component.showResults).toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + + it('should not show results if debounce time not reached', fakeAsync(() => { + component.handleValueChanges(); + + component.control.setValue(searchBy); + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS - 1); + + component.control.valueChanges.subscribe(); + expect(component.showResults).not.toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + + it('should not show results if not enough characters entered', fakeAsync(() => { + component.handleValueChanges(); + + component.control.setValue('q'); + tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS); + + component.control.valueChanges.subscribe(); + expect(component.showResults).not.toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + }); + }); + + describe('ngOnDestroy', () => { + it('should subscribe to value changes', () => { + component.formControlSubscription = new Subscription(); + component.formControlSubscription.unsubscribe = jest.fn(); + + component.ngOnDestroy(); + + expect(component.formControlSubscription.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('set searchResults', () => { + describe('on different results', () => { + it('should call setSearchResults', () => { + component.setSearchResults = jest.fn(); + + component.searchResults = searchResults; + + expect(component.setSearchResults).toHaveBeenCalled(); + }); + }); + + describe('on same results', () => { + it('should not call setSearchResults', () => { + component.setSearchResults = jest.fn(); + + component.searchResults = []; + + expect(component.setSearchResults).not.toHaveBeenCalled(); + }); + }); + + describe('on null or undefined', () => { + it.each([null, undefined])( + 'should not call setSearchResults for %s', + (searchResults: InstantSearchResult<Resource>[]) => { + component.setSearchResults = jest.fn(); + + component.searchResults = searchResults; + + expect(component.setSearchResults).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('setSearchResults', () => { + it('should set results', () => { + component.setSearchResults(searchResults); + + expect(component.results).toEqual(searchResults); + }); + + it('should call buildAriaLiveText with search results length', () => { + component.buildAriaLiveText = jest.fn(); + + component.setSearchResults(searchResults); + + expect(component.buildAriaLiveText).toHaveBeenCalledWith(searchResults.length); + }); + }); + + describe('setFocusOnResultItem', () => { + beforeEach(() => { + component.resultsRef.get = jest.fn().mockReturnValue({ setFocus: jest.fn() }); + }); + + it('should call get for resultsRef with index', () => { + component.setFocusOnResultItem(1); + + expect(component.resultsRef.get).toHaveBeenCalledWith(1); + }); + + it('should call setFocus', () => { + component.setFocusOnResultItem(1); + + expect(component.resultsRef.get(1).setFocus).toHaveBeenCalled(); + }); + }); + + describe('handleArrowNavigation', () => { + const event: KeyboardEvent = new KeyboardEvent('arrow'); + + beforeEach(() => { + component.getResultIndexForKey = jest.fn(); + component.setFocusOnResultItem = jest.fn(); + }); + + it('should call prevent default', () => { + event.preventDefault = jest.fn(); + + component.handleArrowNavigation(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should call getResultIndexForKey', () => { + component.handleArrowNavigation(event); + + expect(component.getResultIndexForKey).toHaveBeenCalledWith(event.key); + }); + + it('should call setFocusOnResultItem', () => { + component.getResultIndexForKey = jest.fn().mockReturnValue(0); + + component.handleArrowNavigation(event); + + expect(component.setFocusOnResultItem).toHaveBeenCalledWith(0); + }); + }); + + describe('handleEscape', () => { + const event: KeyboardEvent = new KeyboardEvent('esc'); + + it('should call prevent default', () => { + event.preventDefault = jest.fn(); + + component.handleEscape(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should call hideResults', () => { + component.hideResults = jest.fn(); + + component.handleEscape(event); + + expect(component.hideResults).toHaveBeenCalled(); + }); + }); + + describe('getNextResultIndex', () => { + it('should return 0 if index is undefined', () => { + const result: number = component.getNextResultIndex(undefined, 2); + + expect(result).toBe(0); + }); + + it('should return 0 if current index is last', () => { + const result: number = component.getNextResultIndex(1, 2); + + expect(result).toBe(0); + }); + + it('should return next search result index', () => { + const result: number = component.getNextResultIndex(0, 2); + + expect(result).toBe(1); + }); + }); + + describe('getPreviousResultIndex', () => { + it('should return last index if current index is undefined', () => { + const result: number = component.getPreviousResultIndex(undefined, 2); + + expect(result).toBe(1); + }); + + it('should return last index if current index is first', () => { + const result: number = component.getPreviousResultIndex(0, 2); + + expect(result).toBe(1); + }); + + it('should return previous search result index', () => { + const result: number = component.getPreviousResultIndex(1, 2); + + expect(result).toBe(0); + }); + }); + + describe('getResultIndexForKey', () => { + it('should call getNextResultIndex if ArrowDown', () => { + component.getNextResultIndex = jest.fn(); + + component.getResultIndexForKey('ArrowDown'); + + expect(component.getNextResultIndex).toHaveBeenCalled(); + }); + + it('should call getPreviousResultIndex if ArrowUp', () => { + component.getPreviousResultIndex = jest.fn(); + + component.getResultIndexForKey('ArrowUp'); + + expect(component.getPreviousResultIndex).toHaveBeenCalled(); + }); + }); + + describe('getLastItemIndex', () => { + it('should return 0', () => { + const result: number = component.getLastItemIndex(0); + + expect(result).toBe(0); + }); + + it('should return decrement of array length', () => { + const result: number = component.getLastItemIndex(5); + + expect(result).toBe(4); + }); + }); + + describe('buildAriaLiveText', () => { + beforeEach(() => { + component.control.setValue('test'); + }); + + it('should return text for one result', () => { + const result: string = component.buildAriaLiveText(1); + + expect(result).toBe( + 'Ein Suchergebnis für Eingabe test. Nutze Pfeiltaste nach unten, um das zu erreichen.', + ); + }); + + it('should return text for many results', () => { + const result: string = component.buildAriaLiveText(4); + + expect(result).toBe( + '4 Suchergebnisse für Eingabe test. Nutze Pfeiltaste nach unten, um diese zu erreichen.', + ); + }); + + it('should return text for no results', () => { + const result: string = component.buildAriaLiveText(0); + + expect(result).toBe('Keine Ergebnisse'); + }); + }); + + describe('showResults', () => { + it('should set isShowResults to true', () => { + component.showResults(); + + expect(component.areResultsVisible).toBe(true); + }); + }); + + describe('hideResults', () => { + it('should set isShowResults to false', () => { + component.hideResults(); + + expect(component.areResultsVisible).toBe(false); + }); + + it('should emit searchResultClosed event', () => { + component.searchResultClosed.emit = jest.fn(); + + component.hideResults(); + + expect(component.searchResultClosed.emit).toHaveBeenCalled(); + }); + }); + + describe('onKeydownHandler', () => { + const keyboardEvent: KeyboardEvent = new KeyboardEvent('a'); + + beforeEach(() => { + component.isSearchResultsEmpty = jest.fn(); + component.isArrowNavigationKey = jest.fn(); + component.isEscapeKey = jest.fn(); + component.handleArrowNavigation = jest.fn(); + component.handleEscape = jest.fn(); + }); + + it('should check for empty result', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isSearchResultsEmpty).toHaveBeenCalled(); + }); + + describe('search result is empty', () => { + beforeEach(() => { + component.isSearchResultsEmpty = jest.fn().mockReturnValue(true); + }); + + it('should ignore key navigation', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isArrowNavigationKey).not.toHaveBeenCalled(); + }); + + it('should ignore escape key handling', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isEscapeKey).not.toHaveBeenCalled(); + }); + }); + + describe('search result is not empty', () => { + beforeEach(() => { + component.isSearchResultsEmpty = jest.fn().mockReturnValue(false); + }); + + it('should check if arrow navigation', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isArrowNavigationKey).toHaveBeenCalled(); + }); + + it('should handle arrow navigation', () => { + component.isArrowNavigationKey = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(keyboardEvent); + + expect(component.handleArrowNavigation).toHaveBeenCalled(); + }); + + it('should not handle arrow navigation', () => { + component.isArrowNavigationKey = jest.fn().mockReturnValue(false); + + component.onKeydownHandler(keyboardEvent); + + expect(component.handleArrowNavigation).not.toHaveBeenCalled(); + }); + + describe('is not arrow navigation', () => { + beforeEach(() => { + component.isArrowNavigationKey = jest.fn().mockReturnValue(false); + }); + + it('should check for escape key', () => { + component.onKeydownHandler(keyboardEvent); + + expect(component.isEscapeKey).toHaveBeenCalled(); + }); + + it('should handle escape key', () => { + component.isEscapeKey = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(keyboardEvent); + + expect(component.handleEscape).toHaveBeenCalled(); + }); + + it('should not handle escape key', () => { + component.isEscapeKey = jest.fn().mockReturnValue(false); + + component.onKeydownHandler(keyboardEvent); + + expect(component.handleEscape).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('isSearchResultsEmpty', () => { + it('should return true', () => { + component.results = []; + + const result: boolean = component.isSearchResultsEmpty(); + + expect(result).toBe(true); + }); + + it('should return false', () => { + component.results = searchResults; + + const result: boolean = component.isSearchResultsEmpty(); + + expect(result).toBe(false); + }); + }); + + describe('isArrowNavigationKey', () => { + it.each(['ArrowUp', 'ArrowDown'])('should return true for key %s', (key: string) => { + const keyboardEvent: KeyboardEvent = { ...new KeyboardEvent('key'), key }; + + const result: boolean = component.isArrowNavigationKey(keyboardEvent); + + expect(result).toBeTruthy(); + }); + + it('should return false', () => { + const result: boolean = component.isArrowNavigationKey(new KeyboardEvent('not arrow')); + + expect(result).toBe(false); + }); + }); + + describe('isEscapeKey', () => { + it('should return true', () => { + const escapeKeyEvent = { ...new KeyboardEvent('esc'), key: 'Escape' }; + + const result: boolean = component.isEscapeKey(escapeKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isEscapeKey(new KeyboardEvent('not escape')); + + expect(result).toBe(false); + }); + }); + + describe('isLastItemOrOutOfArray', () => { + it.each([3, 5])('should return true for %s', (index: number) => { + const result: boolean = component.isLastItemOrOutOfArray(index, 4); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isLastItemOrOutOfArray(1, 3); + + expect(result).toBe(false); + }); + }); + + describe('isFirstItemOrOutOfArray', () => { + it.each([0, -1])('should return true for %s', (index: number) => { + const result: boolean = component.isFirstItemOrOutOfArray(index); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isFirstItemOrOutOfArray(1); + + expect(result).toBe(false); + }); + }); + + describe('onItemClicked', () => { + beforeEach(() => { + component.hideResults = jest.fn(); + }); + + it('should emit searchResultSelected', () => { + component.onItemClicked(searchResults[0], 0); + + expect(searchResultSelected.emit).toHaveBeenCalledWith(searchResults[0]); + }); + + it('should hide results', () => { + component.onItemClicked(searchResults[0], 0); + + expect(component.hideResults).toHaveBeenCalled(); + }); + }); + + describe('onClickHandler', () => { + const e: MouseEvent = { ...new MouseEvent('test') }; + + beforeEach(() => { + component.hideResults = jest.fn(); + }); + + it('should call hideResults if instant search does not contain event target', () => { + component.onClickHandler(e); + + expect(component.hideResults).toHaveBeenCalled(); + }); + + it('should not call hideResults if instant search contains event target', () => { + component.ref.nativeElement.contains = jest.fn().mockReturnValue(true); + + component.onClickHandler(e); + + expect(component.hideResults).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..80a889a7bfa1211c008d3891cfb80d98d811448e --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts @@ -0,0 +1,232 @@ +import { EMPTY_STRING, isNotNil } from '@alfa-client/tech-shared'; +import { CommonModule } from '@angular/common'; +import { + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + ViewChildren, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Resource } from '@ngxp/rest'; +import { isEqual, isUndefined } from 'lodash-es'; +import { Subscription, debounceTime, distinctUntilChanged, filter } from 'rxjs'; +import { AriaLiveRegionComponent } from '../../aria-live-region/aria-live-region.component'; +import { SearchFieldComponent } from '../search-field/search-field.component'; +import { SearchResultHeaderComponent } from '../search-result-header/search-result-header.component'; +import { SearchResultItemComponent } from '../search-result-item/search-result-item.component'; +import { SearchResultLayerComponent } from '../search-result-layer/search-result-layer.component'; +import { InstantSearchQuery, InstantSearchResult } from './instant-search.model'; + +@Component({ + selector: 'ods-instant-search', + standalone: true, + imports: [ + CommonModule, + SearchFieldComponent, + SearchResultHeaderComponent, + SearchResultItemComponent, + SearchResultLayerComponent, + AriaLiveRegionComponent, + ], + template: ` <div class="relative"> + <ods-search-field + [placeholder]="placeholder" + [attr.aria-expanded]="results.length" + [control]="control" + aria-controls="results" + (inputClicked)="showResults()" + (searchQueryCleared)="searchQueryCleared.emit()" + #searchField + /> + <ods-aria-live-region [text]="ariaLiveText" /> + <ods-search-result-layer + *ngIf="results.length && areResultsVisible" + containerClass="absolute z-50 mt-3 max-h-[calc(50vh)] w-full overflow-y-auto" + id="results" + > + <ods-search-result-header + *ngIf="headerText" + [text]="headerText" + [count]="results.length" + header + /> + <ods-search-result-item + *ngFor="let result of results; let i = index" + [title]="result.title" + [description]="result.description" + (itemClicked)="onItemClicked(result, i)" + #results + ></ods-search-result-item> + </ods-search-result-layer> + </div>`, +}) +export class InstantSearchComponent implements OnInit, OnDestroy { + static readonly DEBOUNCE_TIME_IN_MILLIS: number = 300; + + @Input() placeholder: string = EMPTY_STRING; + @Input() headerText: string = EMPTY_STRING; + @Input() control: FormControl<string> = new FormControl(EMPTY_STRING); + + @Input() set searchResults(searchResults: InstantSearchResult<Resource>[]) { + if (!isEqual(searchResults, this.results) && isNotNil(searchResults)) { + this.setSearchResults(searchResults); + } + } + + @Output() searchResultSelected: EventEmitter<InstantSearchResult<Resource>> = new EventEmitter< + InstantSearchResult<Resource> + >(); + @Output() searchResultClosed: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + @Output() searchQueryChanged: EventEmitter<InstantSearchQuery> = + new EventEmitter<InstantSearchQuery>(); + @Output() searchQueryCleared: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + + readonly FIRST_ITEM_INDEX: number = 0; + readonly PREVIEW_SEARCH_STRING_MIN_LENGTH: number = 2; + results: InstantSearchResult<Resource>[] = []; + ariaLiveText: string = ''; + areResultsVisible: boolean = true; + private focusedResult: number | undefined = undefined; + formControlSubscription: Subscription; + + constructor(public ref: ElementRef) {} + + @ViewChildren('results') resultsRef: QueryList<SearchResultItemComponent>; + + ngOnInit(): void { + this.handleValueChanges(); + } + + handleValueChanges() { + this.formControlSubscription = this.control.valueChanges + .pipe( + debounceTime(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS), + filter((value: string) => value.length >= this.PREVIEW_SEARCH_STRING_MIN_LENGTH), + distinctUntilChanged(), + ) + .subscribe((searchBy: string) => { + this.searchQueryChanged.emit({ searchBy }); + if (!this.areResultsVisible) { + this.showResults(); + } + }); + } + + ngOnDestroy(): void { + if (isNotNil(this.formControlSubscription)) this.formControlSubscription.unsubscribe(); + } + + @HostListener('document:keydown', ['$event']) + onKeydownHandler(e: KeyboardEvent): void { + if (this.isSearchResultsEmpty()) return; + if (this.isArrowNavigationKey(e)) this.handleArrowNavigation(e); + if (this.isEscapeKey(e)) this.handleEscape(e); + } + + @HostListener('document:click', ['$event']) + onClickHandler(e: MouseEvent): void { + if (!this.ref.nativeElement.contains(e.target)) { + this.hideResults(); + } + } + + handleArrowNavigation(e: KeyboardEvent): void { + e.preventDefault(); + const newIndex = this.getResultIndexForKey(e.key); + this.focusedResult = newIndex; + this.setFocusOnResultItem(newIndex); + } + + handleEscape(e: KeyboardEvent): void { + e.preventDefault(); + this.hideResults(); + } + + setFocusOnResultItem(index: number): void { + this.resultsRef.get(index).setFocus(); + } + + setSearchResults(searchResults: InstantSearchResult<Resource>[]): void { + this.results = searchResults; + this.ariaLiveText = this.buildAriaLiveText(searchResults.length); + } + + getNextResultIndex(index: number | undefined, resultLength: number): number { + if (isUndefined(index)) return this.FIRST_ITEM_INDEX; + if (this.isLastItemOrOutOfArray(index, resultLength)) return this.FIRST_ITEM_INDEX; + return index + 1; + } + + getPreviousResultIndex(index: number | undefined, resultLength: number): number { + if (isUndefined(index)) return this.getLastItemIndex(resultLength); + if (this.isFirstItemOrOutOfArray(index)) return this.getLastItemIndex(resultLength); + return index - 1; + } + + getLastItemIndex(arrayLength: number): number { + if (arrayLength < 1) return this.FIRST_ITEM_INDEX; + return arrayLength - 1; + } + + getResultIndexForKey(key: string): number { + switch (key) { + case 'ArrowDown': + return this.getNextResultIndex(this.focusedResult, this.results.length); + case 'ArrowUp': + return this.getPreviousResultIndex(this.focusedResult, this.results.length); + default: + console.error('Key %s not allowed', key); + } + } + + buildAriaLiveText(resultsLength: number): string { + if (resultsLength === 1) + return `Ein Suchergebnis für Eingabe ${this.control.value}. Nutze Pfeiltaste nach unten, um das zu erreichen.`; + if (resultsLength > 1) + return `${resultsLength} Suchergebnisse für Eingabe ${this.control.value}. Nutze Pfeiltaste nach unten, um diese zu erreichen.`; + return 'Keine Ergebnisse'; + } + + showResults(): void { + this.areResultsVisible = true; + this.focusedResult = undefined; + } + + hideResults(): void { + this.areResultsVisible = false; + this.focusedResult = undefined; + this.searchResultClosed.emit(); + } + + isLastItemOrOutOfArray(index: number, arrayLength: number): boolean { + return index >= arrayLength - 1; + } + + isFirstItemOrOutOfArray(index: number): boolean { + return index <= this.FIRST_ITEM_INDEX; + } + + isSearchResultsEmpty(): boolean { + return this.results.length === 0; + } + + isArrowNavigationKey(e: KeyboardEvent): boolean { + return e.key === 'ArrowDown' || e.key === 'ArrowUp'; + } + + isEscapeKey(e: KeyboardEvent): boolean { + return e.key === 'Escape'; + } + + onItemClicked(searchResult: InstantSearchResult<Resource>, index: number) { + this.searchResultSelected.emit(searchResult); + this.focusedResult = index; + this.hideResults(); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecf661f429fddde80b83340063ad95f54daba6b0 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts @@ -0,0 +1,11 @@ +import { Resource } from '@ngxp/rest'; + +export interface InstantSearchResult<T extends Resource> { + title: string; + description: string; + data?: T; +} + +export interface InstantSearchQuery { + searchBy: string; +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..498a63c8037118aa5fc5afefc3ca6a4f5095d0e2 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts @@ -0,0 +1,36 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { InstantSearchComponent } from './instant-search.component'; + +const meta: Meta<InstantSearchComponent> = { + title: 'Instant search/Instant search', + component: InstantSearchComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<InstantSearchComponent>; + +export const Default: Story = { + args: { + placeholder: 'zuständige Stelle suchen', + headerText: 'In der OZG-Cloud', + }, +}; + +export const SearchResults: Story = { + args: { + placeholder: 'zuständige Stelle suchen', + headerText: 'In der OZG-Cloud', + searchResults: [ + { + title: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht', + description: 'Fabrikstraße 8-10, 24103 Kiel', + }, + { + title: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck', + description: 'Rathausmarkt 7, Hersbruck', + }, + ], + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..81b3e439ee8eba573c9f17bf31a7a707d02dc911 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts @@ -0,0 +1,55 @@ +import { EMPTY_STRING } from '@alfa-client/tech-shared'; +import { getElementFromFixtureByType, mock } from '@alfa-client/test-utils'; +import { EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { TextInputComponent } from '../../form/text-input/text-input.component'; +import { SearchFieldComponent } from './search-field.component'; + +describe('SearchFieldComponent', () => { + let component: SearchFieldComponent; + let fixture: ComponentFixture<SearchFieldComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchFieldComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('inputClicked', () => { + it('should emit event', () => { + component.inputClicked = <any>mock(EventEmitter); + const input = getElementFromFixtureByType(fixture, TextInputComponent); + + input.inputElement.nativeElement.click(); + + expect(component.inputClicked.emit).toHaveBeenCalled(); + }); + }); + + describe('clearValue', () => { + it('should set empty value', () => { + component.control = new FormControl('test'); + + component.clearInput(); + + expect(component.control.value).toBe(EMPTY_STRING); + }); + + it('should emit searchQueryCleared event', () => { + component.searchQueryCleared.emit = jest.fn(); + + component.clearInput(); + + expect(component.searchQueryCleared.emit).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..787d40064fb28440d7f0b75efe9b94786dc317ad --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts @@ -0,0 +1,46 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { EMPTY_STRING } from '../../../../../tech-shared/src'; +import { TextInputComponent } from '../../form/text-input/text-input.component'; +import { CloseIconComponent } from '../../icons/close-icon/close-icon.component'; +import { SearchIconComponent } from '../../icons/search-icon/search-icon.component'; + +@Component({ + selector: 'ods-search-field', + standalone: true, + imports: [CommonModule, TextInputComponent, SearchIconComponent, CloseIconComponent], + template: `<ods-text-input + label="instant search" + [fieldControl]="control" + [placeholder]="placeholder" + [withPrefix]="true" + [withSuffix]="true" + [showLabel]="false" + (clickEmitter)="inputClicked.emit()" + role="combobox" + > + <ods-search-icon prefix aria-hidden="true" aria-label="Suchfeld" /> + <button + suffix + *ngIf="control.value" + (click)="clearInput()" + aria-label="Eingabe löschen" + data-test-id="clear-instant-search" + > + <ods-close-icon class="fill-primary hover:fill-primary-hover" /> + </button> + </ods-text-input>`, +}) +export class SearchFieldComponent { + @Input() placeholder: string = EMPTY_STRING; + @Input() control: FormControl<string> = new FormControl(EMPTY_STRING); + + @Output() inputClicked: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + @Output() searchQueryCleared: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + + clearInput(): void { + this.control.setValue(EMPTY_STRING); + this.searchQueryCleared.emit(); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f264c9bb83a6de0b30f7b55f2700367a8a91016 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts @@ -0,0 +1,18 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { SearchFieldComponent } from './search-field.component'; + +const meta: Meta<SearchFieldComponent> = { + title: 'Instant search/Search field', + component: SearchFieldComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<SearchFieldComponent>; + +export const Default: Story = { + args: { + placeholder: 'search something...', + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c00b9a408879b4ce0c96c2a374edce5df6739c2 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchResultHeaderComponent } from './search-result-header.component'; + +describe('SearchResultHeaderComponent', () => { + let component: SearchResultHeaderComponent; + let fixture: ComponentFixture<SearchResultHeaderComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultHeaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ba8d7894dbddd157532a1418f175e28fa25c3b0 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts @@ -0,0 +1,17 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-header', + standalone: true, + imports: [CommonModule], + template: ` + <h3 class="mx-6 my-3 w-fit border-b-2 border-primary py-1 text-sm font-semibold text-text"> + {{ text }} ({{ count }}) + </h3> + `, +}) +export class SearchResultHeaderComponent { + @Input({ required: true }) text!: string; + @Input() count: number = 0; +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..089692b213692a024ccf7d6dc07071bd9c25d812 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts @@ -0,0 +1,46 @@ +import { getElementFromFixture, mock } from '@alfa-client/test-utils'; +import { EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { SearchResultItemComponent } from './search-result-item.component'; + +describe('SearchResultItemComponent', () => { + let component: SearchResultItemComponent; + let fixture: ComponentFixture<SearchResultItemComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultItemComponent); + component = fixture.componentInstance; + component.title = 'Test'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('itemClicked', () => { + it('should emit event', () => { + component.itemClicked = <any>mock(EventEmitter); + const button = getElementFromFixture(fixture, getDataTestIdOf('item-button')); + + button.click(); + + expect(component.itemClicked.emit).toHaveBeenCalled(); + }); + }); + + describe('setFocus', () => { + it('should focus native element', () => { + component.buttonRef.nativeElement.focus = jest.fn(); + + component.setFocus(); + + expect(component.buttonRef.nativeElement.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec65fb9ec1774751a28416ba22ae62a27895ad7c --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts @@ -0,0 +1,38 @@ +import { CommonModule } from '@angular/common'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-item', + standalone: true, + imports: [CommonModule], + template: `<button + *ngIf="title" + [ngClass]="[ + 'flex w-full justify-between border-2 border-transparent px-6 py-3', + 'hover:border-focus focus:border-focus focus:outline-none', + ]" + role="listitem" + tabindex="-1" + (click)="itemClicked.emit()" + data-test-id="item-button" + #button + > + <div class="flex flex-col items-start justify-between text-start text-text"> + <p class="text-base font-medium">{{ title }}</p> + <p class="text-sm">{{ description }}</p> + </div> + <ng-content select="[action-button]" /> + </button>`, +}) +export class SearchResultItemComponent { + @Input({ required: true }) title!: string; + @Input() description: string = ''; + + @Output() public itemClicked: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + + @ViewChild('button') buttonRef: ElementRef; + + public setFocus() { + this.buttonRef.nativeElement.focus(); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2c91333316ff8253ec828a228ce83b84514113c --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchResultLayerComponent } from './search-result-layer.component'; + +describe('SearchResultLayerComponent', () => { + let component: SearchResultLayerComponent; + let fixture: ComponentFixture<SearchResultLayerComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultLayerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultLayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f06c873fc8e184fa6eb56a399e67a39633bf95da --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-layer', + standalone: true, + imports: [CommonModule], + template: `<div + [ngClass]="[ + 'rounded-lg border border-primary-600/50 bg-background-50 shadow-lg', + containerClass, + ]" + > + <ng-content select="[header]" /> + <ul role="list"> + <ng-content /> + </ul> + </div>`, +}) +export class SearchResultLayerComponent { + @Input() containerClass: string = ''; +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..02aa94c67050ca8ec652784afb653f56d596454b --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts @@ -0,0 +1,30 @@ +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { SearchResultHeaderComponent } from '../search-result-header/search-result-header.component'; +import { SearchResultItemComponent } from '../search-result-item/search-result-item.component'; +import { SearchResultLayerComponent } from './search-result-layer.component'; + +const meta: Meta<SearchResultLayerComponent> = { + title: 'Instant search/Search result layer', + component: SearchResultLayerComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [SearchResultItemComponent, SearchResultHeaderComponent], + }), + ], +}; + +export default meta; +type Story = StoryObj<SearchResultLayerComponent>; + +export const Default: Story = { + args: {}, + render: () => ({ + template: `<ods-search-result-layer> + <ods-search-result-header text="In der OZG-Cloud" [count]="2" header /> + <ods-search-result-item text="Amt Rantznau - Ordnungsamt" subText="Rathausmarkt 7, Kronshagen" /> + <ods-search-result-item text="Amt Burg-St. Michaelisdonn - Der Amtsvorsteher" subText="Holzmarkt 7, 25712 Rantznau" /> + </ods-search-result-layer>`, + }), +}; diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d624f9674b26a1f03a2e024f0139f9eb8230746 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.spec.ts @@ -0,0 +1,32 @@ +import { dispatchEventFromFixture } from '@alfa-client/test-utils'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PopupListItemComponent } from './popup-list-item.component'; + +describe('PopupListItemComponent', () => { + let component: PopupListItemComponent; + let fixture: ComponentFixture<PopupListItemComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PopupListItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PopupListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('itemClicked emitter', () => { + it('should emit itemClicked', () => { + component.itemClicked.emit = jest.fn(); + + dispatchEventFromFixture(fixture, 'button', 'click'); + + expect(component.itemClicked.emit).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.ts b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bc88c4c30db6091ccf72a4e3658972fe4f117b6 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ods-popup-list-item', + standalone: true, + imports: [CommonModule], + template: `<button + class="flex min-h-12 w-full items-center gap-4 border-2 border-transparent bg-whitetext px-4 py-3 text-start outline-none hover:border-primary focus-visible:border-focus" + role="listitem" + (click)="itemClicked.emit()" + > + <ng-content select="[icon]" /> + <p class="text-text">{{ caption }}</p> + </button>`, +}) +export class PopupListItemComponent { + @Input({ required: true }) caption!: string; + + @Output() itemClicked: EventEmitter<MouseEvent> = new EventEmitter(); +} diff --git a/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.stories.ts b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..a28201314e94cc47b348fd9082f158ec06326174 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup-list-item/popup-list-item.stories.ts @@ -0,0 +1,24 @@ +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; + +import { PopupListItemComponent } from './popup-list-item.component'; + +const meta: Meta<PopupListItemComponent> = { + title: 'Popup/Popup list item', + component: PopupListItemComponent, + decorators: [ + moduleMetadata({ + imports: [PopupListItemComponent], + }), + ], + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<PopupListItemComponent>; + +export const Default: Story = { + args: { + caption: 'List item', + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe6160acf32b4e63623ac2996b32f6222e377188 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.spec.ts @@ -0,0 +1,252 @@ +import { getElementFromFixture } from '@alfa-client/test-utils'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { PopupComponent } from './popup.component'; + +describe('PopupComponent', () => { + let component: PopupComponent; + let fixture: ComponentFixture<PopupComponent>; + const popupButton: string = getDataTestIdOf('popup-button'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PopupComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('togglePopup', () => { + it('should change false to true', () => { + component.isPopupOpen = false; + + component.togglePopup(); + + expect(component.isPopupOpen).toBe(true); + }); + + it('should change true to false', () => { + component.isPopupOpen = true; + + component.togglePopup(); + + expect(component.isPopupOpen).toBe(false); + }); + }); + + describe('handleButtonClick', () => { + beforeEach(() => { + component.togglePopup = jest.fn(); + component.focusList = jest.fn(); + }); + + it('should toggle popup', () => { + component.handleButtonClick(); + + expect(component.togglePopup).toHaveBeenCalled(); + }); + + it('should focus list if popup is visible', () => { + component.isPopupOpen = true; + + component.handleButtonClick(); + + expect(component.focusList).toHaveBeenCalled(); + }); + + it('should not focus list if popup is hidden', () => { + component.handleButtonClick(); + + expect(component.focusList).not.toHaveBeenCalled(); + }); + }); + + describe('focusList', () => { + it('should focus popup list item', fakeAsync(() => { + component.isPopupOpen = true; + fixture.detectChanges(); + component.popupListRef.nativeElement.focus = jest.fn(); + + component.focusList(); + tick(); + + expect(component.popupListRef.nativeElement.focus).toHaveBeenCalled(); + })); + }); + + describe('aria-expanded', () => { + it('should be true if popup is open', () => { + component.isPopupOpen = true; + fixture.detectChanges(); + + const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); + + expect(buttonElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should be false if popup is closed', () => { + component.isPopupOpen = false; + fixture.detectChanges(); + + const buttonElement: HTMLElement = getElementFromFixture(fixture, popupButton); + + expect(buttonElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('isEscapeKey', () => { + it('should return true', () => { + const escapeKeyEvent: KeyboardEvent = { + ...new KeyboardEvent('esc'), + key: 'Escape', + }; + + const result: boolean = component.isEscapeKey(escapeKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const keyEvent: KeyboardEvent = new KeyboardEvent('whatever'); + + const result: boolean = component.isEscapeKey(keyEvent); + + expect(result).toBe(false); + }); + }); + + describe('closePopupAndFocusButton', () => { + beforeEach(() => { + component.isPopupOpen = true; + jest.spyOn(component.buttonRef.nativeElement, 'focus'); + }); + it('should close popup', () => { + component.closePopupAndFocusButton(); + + expect(component.isPopupOpen).toBe(false); + }); + + it('should focus button', () => { + component.closePopupAndFocusButton(); + + expect(component.buttonRef.nativeElement.focus).toHaveBeenCalled(); + }); + }); + + describe('isPopupClosed', () => { + it('should return true', () => { + component.isPopupOpen = false; + + const result: boolean = component.isPopupClosed(); + + expect(result).toBe(true); + }); + + it('should return false', () => { + component.isPopupOpen = true; + + const result: boolean = component.isPopupClosed(); + + expect(result).toBe(false); + }); + }); + + describe('onKeydownHandler', () => { + const e: KeyboardEvent = new KeyboardEvent('test'); + + beforeEach(() => { + component.closePopupAndFocusButton = jest.fn(); + component.isEscapeKey = jest.fn(); + }); + + describe('popup is closed', () => { + beforeEach(() => { + component.isPopupClosed = jest.fn().mockReturnValue(true); + }); + + it('should not check for escape key', () => { + component.onKeydownHandler(e); + + expect(component.isEscapeKey).not.toHaveBeenCalled(); + }); + }); + + describe('popup is open', () => { + beforeEach(() => { + component.isPopupClosed = jest.fn().mockReturnValue(false); + }); + + it('should check for escape key', () => { + component.onKeydownHandler(e); + + expect(component.isEscapeKey).toHaveBeenCalled(); + }); + + it('should handle escape key', () => { + component.isEscapeKey = jest.fn().mockReturnValue(true); + + component.onKeydownHandler(e); + + expect(component.closePopupAndFocusButton).toHaveBeenCalled(); + }); + + it('should not handle escape key', () => { + component.onKeydownHandler(e); + + expect(component.closePopupAndFocusButton).not.toHaveBeenCalled(); + }); + }); + }); + describe('onClickHandler', () => { + const e: MouseEvent = new MouseEvent('test'); + + beforeEach(() => { + component.closePopupAndFocusButton = jest.fn(); + component.buttonRef.nativeElement.contains = jest.fn(); + }); + + describe('popup is closed', () => { + beforeEach(() => { + component.isPopupClosed = jest.fn().mockReturnValue(true); + }); + + it('should not check for button containing event target', () => { + component.onClickHandler(e); + + expect(component.buttonRef.nativeElement.contains).not.toHaveBeenCalled(); + }); + }); + + describe('popup is open', () => { + beforeEach(() => { + component.isPopupClosed = jest.fn().mockReturnValue(false); + }); + + it('should check for button containing event target', () => { + component.onClickHandler(e); + + expect(component.buttonRef.nativeElement.contains).toHaveBeenCalled(); + }); + + it('should handle click', () => { + component.onClickHandler(e); + + expect(component.closePopupAndFocusButton).toHaveBeenCalled(); + }); + + it('should not handle click', () => { + component.buttonRef.nativeElement.contains = jest.fn().mockReturnValue(true); + + component.onClickHandler(e); + + expect(component.closePopupAndFocusButton).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..171397997f1bccc57c87c4a59b014dbd2aa6ed6a --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.component.ts @@ -0,0 +1,86 @@ +import { CdkTrapFocus } from '@angular/cdk/a11y'; +import { CommonModule } from '@angular/common'; +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { twMerge } from 'tailwind-merge'; + +@Component({ + selector: 'ods-popup', + standalone: true, + imports: [CommonModule, CdkTrapFocus], + template: `<div class="relative w-fit"> + <button + [ngClass]="[twMerge('w-fit outline-2 outline-offset-2 outline-focus', buttonClass)]" + (click)="handleButtonClick()" + [attr.aria-expanded]="isPopupOpen" + aria-haspopup="true" + [attr.aria-label]="label" + data-test-id="popup-button" + #button + > + <ng-content select="[button-content]" /> + </button> + <ul + *ngIf="isPopupOpen" + class="absolute max-h-120 min-w-44 max-w-80 animate-fadeIn overflow-y-auto rounded shadow-lg shadow-grayborder focus:outline-none" + [ngClass]="alignTo === 'left' ? 'right-0' : 'left-0'" + role="dialog" + aria-modal="true" + tabIndex="-1" + cdkTrapFocus + #popupList + > + <ng-content /> + </ul> + </div>`, +}) +export class PopupComponent { + @Input() alignTo: 'left' | 'right' = 'left'; + @Input() label: string = ''; + @Input() buttonClass: string = ''; + + isPopupOpen: boolean = false; + readonly twMerge = twMerge; + + @ViewChild('button') buttonRef: ElementRef<HTMLButtonElement>; + @ViewChild('popupList') popupListRef: ElementRef<HTMLUListElement>; + + @HostListener('document:keydown', ['$event']) + onKeydownHandler(e: KeyboardEvent): void { + if (this.isPopupClosed()) return; + if (this.isEscapeKey(e)) this.closePopupAndFocusButton(); + } + + @HostListener('document:click', ['$event']) + onClickHandler(e: MouseEvent): void { + if (this.isPopupClosed()) return; + if (!this.buttonRef.nativeElement.contains(e.target as HTMLElement)) { + this.closePopupAndFocusButton(); + } + } + + handleButtonClick(): void { + this.togglePopup(); + if (this.isPopupOpen) this.focusList(); + } + + focusList(): void { + setTimeout(() => this.popupListRef.nativeElement.focus()); + } + + togglePopup(): void { + this.isPopupOpen = !this.isPopupOpen; + } + + closePopupAndFocusButton(): void { + this.isPopupOpen = false; + this.buttonRef.nativeElement.focus(); + } + + isEscapeKey(e: KeyboardEvent): boolean { + return e.key === 'Escape'; + } + + isPopupClosed(): boolean { + return !this.isPopupOpen; + } +} diff --git a/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts b/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..015112aabc92eee9b30ec4ab36ab84cce8da850c --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/popup/popup/popup.stories.ts @@ -0,0 +1,78 @@ +import { + argsToTemplate, + componentWrapperDecorator, + moduleMetadata, + type Meta, + type StoryObj, +} from '@storybook/angular'; + +import { SaveIconComponent } from '../../icons/save-icon/save-icon.component'; +import { UserIconComponent } from '../../icons/user-icon/user-icon.component'; +import { PopupListItemComponent } from '../popup-list-item/popup-list-item.component'; +import { PopupComponent } from './popup.component'; + +const meta: Meta<PopupComponent> = { + title: 'Popup/Popup', + component: PopupComponent, + decorators: [ + moduleMetadata({ + imports: [PopupComponent, PopupListItemComponent, SaveIconComponent, UserIconComponent], + }), + componentWrapperDecorator((story) => `<div class="flex justify-center mb-32">${story}</div>`), + ], + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<PopupComponent>; + +export const Default: Story = { + args: { alignTo: 'left', label: '', buttonClass: '' }, + argTypes: { + alignTo: { + control: 'select', + options: ['left', 'right'], + table: { + defaultValue: { summary: 'left' }, + }, + }, + buttonClass: { description: 'Tailwind class for button' }, + label: { description: 'Aria-label for button' }, + }, + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <ods-user-icon button-content /> + <ods-popup-list-item caption="Lorem" /> + <ods-popup-list-item caption="Ipsum" /> + <ods-popup-list-item caption="Dolor" /> + </ods-popup>`, + }), +}; + +export const LongText: Story = { + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <p button-content>Trigger popup</p> + <ods-popup-list-item caption="Lorem" /> + <ods-popup-list-item caption="Lorem ipsum dolor sit amet" /> + </ods-popup>`, + }), +}; + +export const ItemsWithIcons: Story = { + render: (args) => ({ + props: args, + template: `<ods-popup ${argsToTemplate(args)}> + <p button-content>Trigger popup</p> + <ods-popup-list-item caption="Lorem"> + <ods-save-icon icon size="small" /> + </ods-popup-list-item> + <ods-popup-list-item caption="Lorem ipsum dolor sit amet"> + <ods-save-icon icon size="small" /> + </ods-popup-list-item> + </ods-popup>`, + }), +}; diff --git a/alfa-client/libs/design-system/src/lib/tailwind-preset/root.css b/alfa-client/libs/design-system/src/lib/tailwind-preset/root.css index 7e997ffe4f9bfb19811dc99c99a708dd8d92e741..10ac4b918b2c3e41d10f5a1a0e41036a3cfdfc03 100644 --- a/alfa-client/libs/design-system/src/lib/tailwind-preset/root.css +++ b/alfa-client/libs/design-system/src/lib/tailwind-preset/root.css @@ -68,3 +68,7 @@ .bescheid-dialog-backdrop { @apply bg-gray-500 bg-opacity-30 transition-opacity; } + +.blur-dialog-backdrop { + @apply bg-gray-400 bg-opacity-75 backdrop-blur-sm transition-opacity dark:bg-gray-500 dark:bg-opacity-75; +} diff --git a/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js b/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js index 1c7b298d5afa17aae60ab57b36b9152cec514121..f998ef819768fb984e0e5c28789c3b93f769497c 100644 --- a/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js +++ b/alfa-client/libs/design-system/src/lib/tailwind-preset/tailwind.config.js @@ -13,7 +13,11 @@ module.exports = { darkMode: 'class', theme: { extend: { - animation: { dash: 'dash 1.5s ease-in-out infinite', 'spin-slow': 'spin 2s linear infinite' }, + animation: { + dash: 'dash 1.5s ease-in-out infinite', + 'spin-slow': 'spin 2s linear infinite', + fadeIn: 'fade-in 0.2s ease-in-out 1', + }, keyframes: { dash: { from: { @@ -29,10 +33,21 @@ module.exports = { 'stroke-dashoffset': '-49', }, }, + 'fade-in': { + '0%': { + opacity: 0, + }, + '100%': { + opacity: 1, + }, + }, }, borderWidth: { 3: '3px', }, + maxHeight: { + 120: '480px', + }, colors: { ozgblue: { 50: 'hsl(200, 100%, 96%)', @@ -56,6 +71,7 @@ module.exports = { 600: 'hsla(0, 0%, 0%, 0.4)', 700: 'hsl(213, 27%, 84%)', 800: 'hsl(0, 0%, 43%)', + 900: 'hsl(0, 0%, 24%)', DEFAULT: 'hsl(0, 0%, 98%)', }, background: { @@ -100,6 +116,12 @@ module.exports = { error: 'hsl(var(--color-error))', focus: 'hsl(var(--color-focus))', }, + backgroundColor: { + greybackdrop: 'rgb(229, 229, 229, 0.95)', + }, + backdropBlur: { + 1: '1px', + }, }, }, plugins: [], diff --git a/alfa-client/libs/design-system/src/test/search.ts b/alfa-client/libs/design-system/src/test/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..93b65de204918bfae7d639ba89d32f30e9c385f3 --- /dev/null +++ b/alfa-client/libs/design-system/src/test/search.ts @@ -0,0 +1,7 @@ +import { faker } from '@faker-js/faker'; +import { Resource } from '@ngxp/rest'; +import { InstantSearchResult } from '../lib/instant-search/instant-search/instant-search.model'; + +export function createInstantSearchResult<T extends Resource>(data?: T): InstantSearchResult<T> { + return { title: faker.random.word(), description: faker.random.words(3), data }; +} diff --git a/alfa-client/libs/tech-shared/src/index.ts b/alfa-client/libs/tech-shared/src/index.ts index 04ea7e3cf1e61a1bdb0d272762084119c1cd6be8..d8d45442dda85dcf0a1e12f412462a448d3eb252 100644 --- a/alfa-client/libs/tech-shared/src/index.ts +++ b/alfa-client/libs/tech-shared/src/index.ts @@ -51,6 +51,7 @@ export * from './lib/pipe/to-traffic-light-tooltip.pipe'; export * from './lib/pipe/to-traffic-light.pipe'; export * from './lib/resource/api-resource.service'; export * from './lib/resource/list-resource.service'; +export * from './lib/resource/resource-search.service'; export * from './lib/resource/resource.model'; export * from './lib/resource/resource.repository'; export * from './lib/resource/resource.rxjs.operator'; diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/convert-for-data-test.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/convert-for-data-test.pipe.ts index e056b77eb22f32ed6c2b9c9765c5101252acef18..cd455ac7098fe6f33d9883b87963e56900d2caec 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/convert-for-data-test.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/convert-for-data-test.pipe.ts @@ -27,7 +27,7 @@ import { convertForDataTest } from '../tech.util'; @Pipe({ name: 'convertForDataTest' }) export class ConvertForDataTestPipe implements PipeTransform { - transform(value: string) { + transform(value: string): string { return isNil(value) ? null : convertForDataTest(value); } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/file-size-plain.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/file-size-plain.pipe.ts index 275302f1ae582e7b6060a4ecb23266cb2eea729e..43492c8fac7194f393a0fed2f69635372804d255 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/file-size-plain.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/file-size-plain.pipe.ts @@ -6,13 +6,13 @@ export class FileSizePlainPipe implements PipeTransform { readonly MB = Math.pow(this.kB, 2); readonly GB = Math.pow(this.kB, 3); - transform(size: number) { + transform(size: number): string { if (size >= this.GB) return this.formatFileSize(size / this.GB, 'GB'); if (size >= this.MB) return this.formatFileSize(size / this.MB, 'MB'); return this.formatFileSize(size / this.kB, 'kB'); } - private formatFileSize(number: number, unit: string) { + private formatFileSize(number: number, unit: string): string { return `${number.toFixed(2).replace('.', ',')} ${unit}`; } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/file-size.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/file-size.pipe.ts index a8ca257d0b3687a5684b809694a56c421ae43ffd..31fb3d58dd0a46c21578be37e853e3b09997825e 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/file-size.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/file-size.pipe.ts @@ -29,13 +29,13 @@ export class FileSizePipe implements PipeTransform { readonly MB = Math.pow(this.kB, 2); readonly GB = Math.pow(this.kB, 3); - transform(size: number) { + transform(size: number): string { if (size >= this.GB) return this.formatFileSize(size / this.GB, 'GB'); if (size >= this.MB) return this.formatFileSize(size / this.MB, 'MB'); return this.formatFileSize(size / this.kB, 'kB'); } - private formatFileSize(number: number, unit: string) { + private formatFileSize(number: number, unit: string): string { return `${number.toFixed(2).replace('.', ',')}<span class="unit">${unit}</span>`; } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.spec.ts b/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.spec.ts index d28782d0fef619b1cb5297d23e938957686b0b02..7387cf4d9b4d7279a9b8bddecedc0b48a670f6b7 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.spec.ts @@ -1,5 +1,5 @@ import faker from '@faker-js/faker'; -import { Resource } from '@ngxp/rest'; +import { Resource, ResourceUri } from '@ngxp/rest'; import { GetUrlPipe } from './get-url.pipe'; describe('GetUrlPipe', () => { @@ -15,7 +15,7 @@ describe('GetUrlPipe', () => { const pipe: GetUrlPipe = new GetUrlPipe(); it('should return resource url', () => { - const result: string = pipe.transform(resource, selfLink); + const result: ResourceUri = pipe.transform(resource, selfLink); expect(result).toBe(url); }); diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.ts index 0c130a8548597cfbcac2bf8d911b5bd1ee6ab175..32a4ee089847b5cd38365b6cd52683ba06b81203 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/get-url.pipe.ts @@ -1,9 +1,9 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { Resource, getUrl } from '@ngxp/rest'; +import { Resource, ResourceUri, getUrl } from '@ngxp/rest'; @Pipe({ name: 'getUrl' }) export class GetUrlPipe implements PipeTransform { - transform(resource: Resource, link: string) { + transform(resource: Resource, link: string): ResourceUri { return getUrl(resource, link); } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/has-link.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/has-link.pipe.ts index 70ac58beccf1b77fe59f7c50bd0b0fa2a0f6f254..311e6b39d2816155469a906027f76784881a5155 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/has-link.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/has-link.pipe.ts @@ -22,11 +22,11 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { Pipe, PipeTransform } from '@angular/core'; -import { hasLink, Resource } from '@ngxp/rest'; +import { Resource, hasLink } from '@ngxp/rest'; @Pipe({ name: 'hasLink' }) export class HasLinkPipe implements PipeTransform { - transform(resource: Resource, link: string) { + transform(resource: Resource, link: string): boolean { return hasLink(resource, link); } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/not-has-link.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/not-has-link.pipe.ts index 8f8f786c562cf744e412b91f62a66827897535c7..e7bb7f28d2e877e4918e6119103238b839c05e56 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/not-has-link.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/not-has-link.pipe.ts @@ -22,11 +22,11 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { Pipe, PipeTransform } from '@angular/core'; -import { hasLink, Resource } from '@ngxp/rest'; +import { Resource, hasLink } from '@ngxp/rest'; @Pipe({ name: 'notHasLink' }) export class NotHasLinkPipe implements PipeTransform { - transform(resource: Resource, link: string) { + transform(resource: Resource, link: string): boolean { return !hasLink(resource, link); } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.spec.ts b/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.spec.ts index 309a04197539defcfa29b8320840d4f84aab7721..eef4a6cad0cd8c46e932d45a93b11eab29005b62 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.spec.ts @@ -23,35 +23,43 @@ */ import { getEmbeddedResource } from '@ngxp/rest'; import { DummyListLinkRel } from 'libs/tech-shared/test/dummy'; -import { createDummyListResource } from 'libs/tech-shared/test/resource'; +import { createDummyListResource, toResource } from 'libs/tech-shared/test/resource'; +import { ListResource } from '../resource/resource.util'; +import { EMPTY_ARRAY } from '../tech.util'; import { ToEmbeddedResourcesPipe } from './to-embedded-resource.pipe'; describe('ToEmbeddedResourcesPipe', () => { - const pipe = new ToEmbeddedResourcesPipe(); + const pipe: ToEmbeddedResourcesPipe = new ToEmbeddedResourcesPipe(); it('create an instance', () => { expect(pipe).toBeTruthy(); }); describe('transform listResource to resources by link', () => { - const listResource = createDummyListResource([DummyListLinkRel.LIST]); + const listResource: ListResource = createDummyListResource([DummyListLinkRel.LIST]); it('should return embedded resources', () => { - const result = pipe.transform(listResource, DummyListLinkRel.LIST); + const result: unknown[] = pipe.transform(listResource, DummyListLinkRel.LIST); expect(result).toBe(getEmbeddedResource(listResource, DummyListLinkRel.LIST)); }); - it('should return null by empty listResource', () => { - const result = pipe.transform(null, DummyListLinkRel.LIST); + it('should return an empty array on null as listResource', () => { + const result: unknown[] = pipe.transform(null, DummyListLinkRel.LIST); - expect(result).toBe(null); + expect(result).toBe(EMPTY_ARRAY); }); - it('should return null by empty linkel', () => { - const result = pipe.transform(listResource, null); + it('should return empty array on null as linkel', () => { + const result: unknown[] = pipe.transform(listResource, null); - expect(result).toBe(null); + expect(result).toBe(EMPTY_ARRAY); + }); + + it('should return empty array non existing resources', () => { + const result: unknown[] = pipe.transform(toResource({}), DummyListLinkRel.LIST); + + expect(result).toBe(EMPTY_ARRAY); }); }); }); diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.ts index 53aec73b8461648669ba4f95b7d4aa1ecb3c5021..f310e40a994c86031869c1137adf3a3387864b38 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/to-embedded-resource.pipe.ts @@ -22,13 +22,17 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { Pipe, PipeTransform } from '@angular/core'; -import { getEmbeddedResource } from '@ngxp/rest'; -import { isNil } from 'lodash-es'; +import { Resource, getEmbeddedResource } from '@ngxp/rest'; +import { isNil, isNull } from 'lodash-es'; +import { LinkRelationName } from '../resource/resource.model'; +import { ListResource } from '../resource/resource.util'; +import { EMPTY_ARRAY } from '../tech.util'; @Pipe({ name: 'toEmbeddedResources' }) export class ToEmbeddedResourcesPipe implements PipeTransform { - transform(listResource: any, linkRel: any) { - if (isNil(listResource) || isNil(linkRel)) return null; - return getEmbeddedResource(listResource, linkRel); + transform(listResource: ListResource, linkRel: LinkRelationName): Resource[] { + if (isNil(listResource) || isNil(linkRel)) return EMPTY_ARRAY; + const embeddedReources: Resource[] = getEmbeddedResource(listResource, linkRel); + return isNull(embeddedReources) ? EMPTY_ARRAY : embeddedReources; } } diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.spec.ts b/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.spec.ts index 6f709453705f390368bf511ccef753555b246769..389a82c47e1526c02c5464eb41cddc507d86c49e 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.spec.ts @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { getUrl, Resource } from '@ngxp/rest'; +import { getUrl, Resource, ResourceUri } from '@ngxp/rest'; import { createDummyResource } from 'libs/tech-shared/test/resource'; import { decodeUrlFromEmbedding } from '../tech.util'; import { ToResourceUriPipe } from './to-resource-uri.pipe'; @@ -38,14 +38,14 @@ describe('ToResourceUriPipe', () => { const dummyResource: Resource = createDummyResource(); it('output as expected', () => { - const pipeResult: string = pipe.transform(dummyResource); + const pipeResult: ResourceUri = pipe.transform(dummyResource); const result: string = decodeUrlFromEmbedding(pipeResult); expect(result).toBe(getUrl(dummyResource)); }); it('replace "/" with "_" if necessary', () => { - const pipeResult: string = pipe.transform({ + const pipeResult: ResourceUri = pipe.transform({ _links: { self: { href: base64ResultWithSlash } }, }); @@ -60,7 +60,7 @@ describe('ToResourceUriPipe', () => { const dummyResourceWithLink: Resource = createDummyResource([linkRel]); it('output as expected', () => { - const pipeResult: string = pipe.transform(dummyResourceWithLink, linkRel); + const pipeResult: ResourceUri = pipe.transform(dummyResourceWithLink, linkRel); const result: string = decodeUrlFromEmbedding(pipeResult); diff --git a/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.ts b/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.ts index 4c1d7971cd774eb1b53fc0b6bda21744f1a5235b..30d6f4c7e466d6f7e43075af5bc7f74a534c6557 100644 --- a/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.ts +++ b/alfa-client/libs/tech-shared/src/lib/pipe/to-resource-uri.pipe.ts @@ -22,13 +22,13 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { Pipe, PipeTransform } from '@angular/core'; -import { Resource } from '@ngxp/rest'; +import { Resource, ResourceUri } from '@ngxp/rest'; import { isNil } from 'lodash-es'; import { toResourceUri } from '../resource/resource.util'; @Pipe({ name: 'toResourceUri' }) export class ToResourceUriPipe implements PipeTransform { - transform(resource: Resource, linkRel?: string): string { + transform(resource: Resource, linkRel?: string): ResourceUri { if (isNil(resource)) { return null; } diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c1d30cbcd83ef02da378247d4e2792ff750d3a0 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.spec.ts @@ -0,0 +1,206 @@ +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import { fakeAsync, tick } from '@angular/core/testing'; +import faker from '@faker-js/faker'; +import { Resource } from '@ngxp/rest'; +import { DummyLinkRel } from 'libs/tech-shared/test/dummy'; +import { createDummyListResource, createDummyResource } from 'libs/tech-shared/test/resource'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { singleColdCompleted } from '../../../test/marbles'; +import { EMPTY_STRING } from '../tech.util'; +import { ResourceSearchService } from './resource-search.service'; +import { LinkRelationName, ListItemResource, SearchResourceServiceConfig } from './resource.model'; +import { ResourceRepository } from './resource.repository'; +import { + ListResource, + StateResource, + createEmptyStateResource, + createStateResource, +} from './resource.util'; + +describe('ResourceSearchService', () => { + let service: ResourceSearchService<Resource, ListResource, ListItemResource>; + let config: SearchResourceServiceConfig<Resource>; + let repository: Mock<ResourceRepository>; + + const baseResource: Resource = createDummyResource(); + const baseResourceSubj: BehaviorSubject<Resource> = new BehaviorSubject<Resource>(baseResource); + const searchLinkRel: LinkRelationName = DummyLinkRel.DUMMY; + + const listResource: ListResource = createDummyListResource(); + const stateListResource: StateResource<ListResource> = createStateResource(listResource); + + const searchBy: string = faker.random.words(2); + + beforeEach(() => { + config = { + baseResource: baseResourceSubj, + searchLinkRel, + }; + repository = mock(ResourceRepository); + + service = new ResourceSearchService(config, useFromMock(repository)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('get result list', () => { + beforeEach(() => { + service.handleChanges = jest.fn(); + + service.listResource.next(stateListResource); + service.searchBy.next(searchBy); + }); + + it('should call handleChanges', fakeAsync(() => { + service.getResultList().subscribe(); + tick(); + + expect(service.handleChanges).toHaveBeenCalledWith(stateListResource, searchBy, baseResource); + })); + + it('should return value', (done) => { + service.getResultList().subscribe((resultList) => { + expect(resultList).toBe(stateListResource); + done(); + }); + }); + }); + + describe('handle changes', () => { + it('should call doSearch on loading flag', () => { + service.doSearch = jest.fn(); + + service.handleChanges({ ...stateListResource, loading: true }, searchBy, baseResource); + + expect(service.doSearch).toHaveBeenCalledWith(searchBy, baseResource); + }); + + it('should NOT call doSearch on loading flag false', () => { + service.doSearch = jest.fn(); + + service.handleChanges({ ...stateListResource, loading: false }, searchBy, baseResource); + + expect(service.doSearch).not.toHaveBeenCalled(); + }); + }); + + describe('do search', () => { + beforeEach(() => { + service.dispatchSearch = jest.fn(); + }); + describe('on existing searchBy', () => { + it('should call dispatchSearch', () => { + service.doSearch(searchBy, baseResource); + + expect(service.dispatchSearch).toHaveBeenCalledWith(baseResource, searchLinkRel, searchBy); + }); + + it('should NOT clear list resource', () => { + service.listResource.next(stateListResource); + + service.doSearch(searchBy, baseResource); + + expect(service.listResource.value).toBe(stateListResource); + }); + }); + + describe('on empty searchBy', () => { + it('should clear list resource', () => { + service.listResource.next(stateListResource); + + service.doSearch(EMPTY_STRING, baseResource); + + expect(service.listResource.value).toEqual(createEmptyStateResource()); + }); + + it('should NOT call dispatchSearch', () => { + service.doSearch(EMPTY_STRING, baseResource); + + expect(service.dispatchSearch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('dispatch search', () => { + beforeEach(() => { + repository.search.mockReturnValue(of(listResource)); + }); + + it('should call respository', () => { + service.dispatchSearch(baseResource, searchLinkRel, searchBy); + + expect(repository.search).toHaveBeenCalledWith(baseResource, searchLinkRel, searchBy); + }); + + it('should update list resource', () => { + service.listResource.next(createEmptyStateResource()); + + service.dispatchSearch(baseResource, searchLinkRel, searchBy); + + expect(service.listResource.value).toEqual(createStateResource(listResource)); + }); + }); + + describe('clear search list', () => { + it('should call dispatchClearSearch listResource by given result', () => { + service.listResource.next(stateListResource); + + service.clearResultList(); + + expect(service.listResource.value).toEqual(createEmptyStateResource()); + }); + }); + + describe('saerch', () => { + it('should set searchBy', () => { + service.search(searchBy); + + expect(service.searchBy.value).toEqual(searchBy); + }); + + it('should set list resource loading', () => { + service.listResource.next(stateListResource); + + service.search(searchBy); + + expect(service.listResource.value).toEqual({ ...stateListResource, loading: true }); + }); + }); + + describe('get selected', () => { + const dummyResource: Resource = createDummyResource(); + + it('should return selected resource', () => { + service.getSelectedResult = jest.fn().mockReturnValue(of(dummyResource)); + const selectedResult$: Observable<Resource> = service.getSelectedResult(); + + expect(selectedResult$).toBeObservable(singleColdCompleted(dummyResource)); + }); + }); + + describe('select result', () => { + const dummyResource: Resource = createDummyResource(); + + it('should update selected resource', () => { + service.selectedResource.next(null); + + service.selectResult(dummyResource); + + expect(service.selectedResource.value).toEqual(dummyResource); + }); + }); + + describe('clear select result', () => { + const dummyResource: Resource = createDummyResource(); + + it('should update selected resource to null', () => { + service.selectedResource.next(dummyResource); + + service.clearSelectedResult(); + + expect(service.selectedResource.value).toBeNull(); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8e52663e186ba7c2ebe4b8d6bec0dd593163e3a --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource-search.service.ts @@ -0,0 +1,105 @@ +import { Resource } from '@ngxp/rest'; +import { isEmpty } from 'lodash-es'; +import { BehaviorSubject, Observable, first, map, tap, withLatestFrom } from 'rxjs'; +import { EMPTY_STRING, isNotEmpty } from '../tech.util'; +import { LinkRelationName, ListItemResource, SearchResourceServiceConfig } from './resource.model'; +import { ResourceRepository } from './resource.repository'; +import { + ListResource, + StateResource, + createEmptyStateResource, + createStateResource, +} from './resource.util'; + +/** + * B = Type of baseresource + * T = Type of listresource + * I = Type of items in listresource + */ +export class ResourceSearchService< + B extends Resource, + T extends ListResource, + I extends ListItemResource, +> { + readonly listResource: BehaviorSubject<StateResource<T>> = new BehaviorSubject( + createEmptyStateResource(), + ); + readonly searchBy: BehaviorSubject<string> = new BehaviorSubject<string>(EMPTY_STRING); + readonly selectedResource: BehaviorSubject<I> = new BehaviorSubject<I>(null); + + constructor( + private config: SearchResourceServiceConfig<B>, + private repository: ResourceRepository, + ) {} + + public getResultList(): Observable<StateResource<T>> { + return this.selectListResource().pipe( + withLatestFrom(this.selectSearchBy(), this.config.baseResource), + tap(([listResource, searchBy, baseResource]) => { + this.handleChanges(listResource, searchBy, baseResource); + }), + map(([listResource]) => listResource), + ); + } + + private selectListResource(): Observable<StateResource<T>> { + return this.listResource.asObservable(); + } + + private selectSearchBy(): Observable<string> { + return this.searchBy.asObservable(); + } + + handleChanges(listResource: StateResource<T>, searchBy: string, baseResource: B): void { + if (listResource.loading) this.doSearch(searchBy, baseResource); + } + + doSearch(searchBy: string, baseResource: B): void { + if (isNotEmpty(searchBy)) { + this.dispatchSearch(baseResource, this.config.searchLinkRel, searchBy); + } + if (isEmpty(searchBy)) { + this.dispatchClearSearchList(); + } + } + + dispatchSearch(baseResource: B, linkRel: LinkRelationName, searchBy: string): void { + this.repository + .search<T>(baseResource, linkRel, searchBy) + .pipe(first()) + .subscribe((result: T) => this.dispatchSearchSuccess(result)); + } + + private dispatchSearchSuccess(result: T): void { + this.listResource.next(createStateResource(result)); + } + + public clearResultList(): void { + this.dispatchClearSearchList(); + } + + private dispatchClearSearchList(): void { + this.listResource.next(createEmptyStateResource()); + } + + public search(searchBy: string): void { + this.dispatchInitSearch(searchBy); + } + + private dispatchInitSearch(searchBy: string): void { + this.searchBy.next(searchBy); + this.listResource.next({ ...this.listResource.value, loading: true }); + } + + public getSelectedResult(): Observable<I> { + return this.selectedResource.asObservable(); + } + + public selectResult(selected: I): void { + this.selectedResource.next(selected); + } + + public clearSelectedResult(): void { + this.selectedResource.next(null); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts index f08c137d763e1557b6596bc91c0e49c92e214f8f..e0ff111520bfa7279e74fb9c7546e4c2ff68966b 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts @@ -42,3 +42,8 @@ interface ConfigAction { linkRel: LinkRelationName; order?: string; } + +export interface SearchResourceServiceConfig<B> { + baseResource: Observable<B>; + searchLinkRel: LinkRelationName; +} diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.spec.ts index d9b2b8cd5662996a5c8eb0129e54779ee289a105..2c8c97bfd4304f4bad5545a11cbe97dcb8a7258f 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.spec.ts @@ -203,4 +203,62 @@ describe('ResourceRepository', () => { expect(result).toBeObservable(singleHot(deletedResource)); }); }); + + describe('search', () => { + const linkRel: string = DummyLinkRel.DUMMY; + const dummyResource: Resource = createDummyResource([linkRel]); + const searchBy: string = faker.random.word(); + + const searchUrl: string = faker.internet.url(); + + const dummyListResource: ListResource = createDummyListResource(); + + it('should call buildSearchUri', () => { + repository.buildSearchUri = jest.fn(); + + repository.search(dummyResource, linkRel, searchBy); + + expect(repository.buildSearchUri).toHaveBeenCalledWith( + new URL(getUrl(dummyResource, linkRel)), + searchBy, + ); + }); + + it('should call resourceFactory', () => { + repository.buildSearchUri = jest.fn().mockReturnValue(searchUrl); + + repository.search(dummyResource, linkRel, searchBy); + + expect(resourceFactory.fromId).toHaveBeenCalledWith(searchUrl); + }); + + it('should call resourceWrapper', () => { + repository.buildSearchUri = jest.fn().mockReturnValue(searchUrl); + + repository.search(dummyResource, linkRel, searchBy); + + expect(resourceWrapper.get).toHaveBeenCalled(); + }); + + it('should return result', () => { + repository.buildSearchUri = jest.fn().mockReturnValue(searchUrl); + resourceWrapper.get.mockReturnValue(singleCold(dummyListResource)); + + const result: Observable<Resource> = repository.search(dummyResource, linkRel, searchBy); + + expect(result).not.toBeNull(); + expect(result).toBeObservable(singleHot(dummyListResource)); + }); + + describe('build search uri', () => { + const url: URL = new URL('http://test/searchit?searchBy={searchBy}'); + const searchBy: string = faker.random.word(); + + it('should return uri contains searchBy param', () => { + const searchUri: ResourceUri = repository.buildSearchUri(url, searchBy); + + expect(searchUri).toEqual(`http://test/searchit?searchBy=${searchBy}`); + }); + }); + }); }); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts index 2328613ee2ab9f1d74b2fcb1e42ba5a527b2f5c8..1fb41fc5de6d2aca790bfc391cda14937de16bd1 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts @@ -6,6 +6,8 @@ import { ListResource } from './resource.util'; @Injectable({ providedIn: 'root' }) export class ResourceRepository { + static SEARCH_PARAM: string = 'searchBy'; + constructor(private resourceFactory: ResourceFactory) {} public getListResource(resource: Resource, linkRel: string): Observable<ListResource> { @@ -39,4 +41,15 @@ export class ResourceRepository { public delete(resource: Resource, linkRel: LinkRelationName): Observable<Resource> { return this.resourceFactory.from(resource).delete(linkRel); } + + public search<T>(resource: Resource, linkRel: LinkRelationName, searchBy: string): Observable<T> { + return this.resourceFactory + .fromId(this.buildSearchUri(new URL(getUrl(resource, linkRel)), searchBy)) + .get(); + } + + buildSearchUri(url: URL, searchBy: string): ResourceUri { + url.searchParams.set(ResourceRepository.SEARCH_PARAM, searchBy); + return url.href; + } } diff --git a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts index 1af38f9f8065ae4685acb7326d1a65b3d88fcd69..956266f7c268dc575e7e29307b0530a31555f43f 100644 --- a/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts +++ b/alfa-client/libs/tech-shared/src/lib/service/formservice.abstract.ts @@ -32,7 +32,7 @@ import { HttpError, InvalidParam, ProblemDetail } from '../tech.model'; import { isNotUndefined } from '../tech.util'; import { setInvalidParamValidationError } from '../validation/tech.validation.util'; -export abstract class AbstractFormService { +export abstract class AbstractFormService<T extends Resource = Resource> { form: UntypedFormGroup; pathPrefix: string; source: any; @@ -46,17 +46,17 @@ export abstract class AbstractFormService { protected abstract initForm(): UntypedFormGroup; public submit( - afterSubmit: OperatorFunction<StateResource<Resource>, StateResource<Resource>> = identity, - ): Observable<StateResource<Resource | HttpError>> { + afterSubmit: OperatorFunction<StateResource<T>, StateResource<T>> = identity, + ): Observable<StateResource<T | HttpError>> { return this.doSubmit().pipe( afterSubmit, map((result) => this.handleResponse(result)), ); } - protected abstract doSubmit(): Observable<StateResource<Resource | HttpError>>; + protected abstract doSubmit(): Observable<StateResource<T | HttpError>>; - handleResponse(result: StateResource<Resource | HttpError>): StateResource<Resource | HttpError> { + handleResponse(result: StateResource<T | HttpError>): StateResource<T | HttpError> { if (result.loading) return result; if (hasStateResourceError(result)) { this.handleError(result.error); diff --git a/alfa-client/libs/tech-shared/test/data-test.ts b/alfa-client/libs/tech-shared/test/data-test.ts index d0c53e43d65c6547e211bce4ac0ef9bfb887b9b4..4b4b9cc2b8a1661880fbe10002e8d1b3a0a02c7a 100644 --- a/alfa-client/libs/tech-shared/test/data-test.ts +++ b/alfa-client/libs/tech-shared/test/data-test.ts @@ -28,3 +28,7 @@ export function getDataTestClassOf(value: string): string { export function getDataTestIdOf(value: string): string { return `[data-test-id="${value}"]`; } + +export function getDataTestIdAttributeOf(value: string): string { + return `[dataTestId="${value}"]`; +} diff --git a/alfa-client/libs/test-utils/src/index.ts b/alfa-client/libs/test-utils/src/index.ts index 2496647d81ac7e86f904aef5e70b2031539d4541..9500ab27220d6d74064d30a8d163c00f020ce2f7 100644 --- a/alfa-client/libs/test-utils/src/index.ts +++ b/alfa-client/libs/test-utils/src/index.ts @@ -21,7 +21,9 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +export * from './lib/dialog'; export * from './lib/helper'; export * from './lib/jest.helper'; export * from './lib/mocking'; +export * from './lib/model'; export * from './lib/test-utils.module'; diff --git a/alfa-client/libs/test-utils/src/lib/dialog.ts b/alfa-client/libs/test-utils/src/lib/dialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9a32f03a17d554f48e5263d1df4c4b7bb552b9b --- /dev/null +++ b/alfa-client/libs/test-utils/src/lib/dialog.ts @@ -0,0 +1 @@ +export const dialogRefMock = { close: jest.fn() }; diff --git a/alfa-client/libs/test-utils/src/lib/helper.ts b/alfa-client/libs/test-utils/src/lib/helper.ts index ed3e6fa137f9399a1ce428b627c13b47a70947f9..b95ce2e05c2a1912d502259ff1172ae34b16959b 100644 --- a/alfa-client/libs/test-utils/src/lib/helper.ts +++ b/alfa-client/libs/test-utils/src/lib/helper.ts @@ -24,6 +24,7 @@ import { DebugElement, Type } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { EventData } from './model'; export function getElementFromFixtureByType<T>( fixture: ComponentFixture<any>, @@ -47,8 +48,8 @@ export function getElementsFromFixture(fixture: ComponentFixture<any>, htmlEleme return fixture.nativeElement.querySelectorAll(htmlElement); } -export function dispatchEventFromFixture( - fixture: ComponentFixture<any>, +export function dispatchEventFromFixture<T>( + fixture: ComponentFixture<T>, elementSelector: string, event: string, ): void { @@ -56,6 +57,14 @@ export function dispatchEventFromFixture( element.nativeElement.dispatchEvent(new Event(event)); } +export function triggerEvent<T>(eventData: EventData<T>) { + const element: DebugElement = getDebugElementFromFixtureByCss( + eventData.fixture, + eventData.elementSelector, + ); + element.triggerEventHandler(eventData.name, eventData.data); +} + export function getDebugElementFromFixtureByCss( fixture: ComponentFixture<any>, query: string, diff --git a/alfa-client/libs/test-utils/src/lib/model.ts b/alfa-client/libs/test-utils/src/lib/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..e48d4ed88c7c2447665811a86fc712dc2a4904da --- /dev/null +++ b/alfa-client/libs/test-utils/src/lib/model.ts @@ -0,0 +1,8 @@ +import { ComponentFixture } from '@angular/core/testing'; + +export interface EventData<T> { + fixture: ComponentFixture<T>; + elementSelector: string; + name: string; + data?: unknown; +} diff --git a/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.spec.ts b/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.spec.ts index 90ba85e40ec86b4c785793fecc5e5e6b0d4e850e..8fac9311bbcd78efbdd449922e9901f6cd97aa45 100644 --- a/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.spec.ts +++ b/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.spec.ts @@ -10,12 +10,20 @@ describe('OzgcloudDialogService', () => { const component = <any>{ name: 'Component' }; const dialogData = { id: 'ZumBeispiel' }; + const dialogConfig: DialogConfig = { + hasBackdrop: true, + disableClose: true, + }; const dialogConfigWithData: DialogConfig = { data: dialogData }; const viewContainerRef = <any>{}; const dialogConfigWithDataAndViewContainerRef: DialogConfig = { data: dialogData, viewContainerRef, }; + const dialogConfigWithOwnConfigAndViewContainerRef: DialogConfig = { + ...dialogConfig, + viewContainerRef, + }; const dialogConfigWithOutDataAndWithViewContainerRef: DialogConfig = { viewContainerRef, }; @@ -59,7 +67,16 @@ describe('OzgcloudDialogService', () => { expect(dialog.open).toHaveBeenCalledWith(component, dialogConfigWithDataAndViewContainerRef); }); - it('should open dialog wihtout data', () => { + it('should open dialog with custom config', () => { + service.openInCallingComponentContext(component, viewContainerRef, null, dialogConfig); + + expect(dialog.open).toHaveBeenCalledWith( + component, + dialogConfigWithOwnConfigAndViewContainerRef, + ); + }); + + it('should open dialog without data and custom config', () => { service.openInCallingComponentContext(component, viewContainerRef); expect(dialog.open).toHaveBeenCalledWith( diff --git a/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.ts b/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.ts index 27769a03b7ebd03c1db3a8ad50ee02d5b5ce9a69..3c9383b4a60f1293b42ee00b3acc3fa90a54abaf 100644 --- a/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.ts +++ b/alfa-client/libs/ui/src/lib/ui/ozgcloud-dialog/ozgcloud-dialog.service.ts @@ -30,8 +30,12 @@ export class OzgcloudDialogService { component: ComponentType<T>, viewContainerRef: ViewContainerRef, data?: D, + dialogConfig?: DialogConfig, ): DialogRef<T> { - return this.openDialog(component, this.buildDialogConfigWithData(data, { viewContainerRef })); + return this.openDialog( + component, + this.buildDialogConfigWithData(data, { viewContainerRef, ...dialogConfig }), + ); } private buildDialogConfigWithData<D>(data: D, dialogConfig?: DialogConfig): DialogConfig | null { diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.html b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.html index eaa8140904dd29c50e275cf12f0a7ac6df021715..2d16e8047196cf952d24fea9f905b058d7a3e9d9 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.html +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.html @@ -29,7 +29,7 @@ <div class="flex min-h-full grow flex-col divide-y divide-gray-200 border-l border-r border-gray-200 dark:divide-background-100 dark:border-transparent lg:flex-row lg:divide-x lg:divide-y-0" > - <div class="grow"> + <div class="w-full grow"> <alfa-vorgang-detail-header [vorgangWithEingang]="vorgangResource" data-test-id="vorgang-detail-header" @@ -52,6 +52,20 @@ ></alfa-vorgang-detail-formular-buttons> </div> + <div + class="section one-column" + *ngIf="vorgangResource | hasLink: vorgangWithEingangLinkRel.COLLABORATIONS" + > + <ozgcloud-expansion-panel + headline="Zusammenarbeit" + data-test-id="collaboration-expansion-panel" + > + <alfa-collaboration-in-vorgang-container + data-test-id="collaboration-in-voragng-container" + ></alfa-collaboration-in-vorgang-container> + </ozgcloud-expansion-panel> + </div> + <div class="two-column"> <div class="section" @@ -78,7 +92,7 @@ </div> </div> </div> - <div class="flex h-full min-w-80 flex-1 flex-col px-4 py-3"> + <div class="flex w-[calc(100vw-2.5rem)] flex-col px-6 py-4 lg:w-80 lg:px-3 lg:py-4"> <alfa-vorgang-detail-antragsteller [antragsteller]="vorgangResource.eingang.antragsteller" data-test-id="vorgang-detail-antragsteller" diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.spec.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.spec.ts index 34870cb494be2e9160e6101076214483ad95ff0b..7786b45a3e33dc073681a9cb53887a78d3a9587e 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.spec.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.spec.ts @@ -21,6 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { CollaborationInVorgangContainerComponent } from '@alfa-client/collaboration'; import { VorgangForwardingContainerComponent } from '@alfa-client/forwarding'; import { KommentarListInVorgangContainerComponent } from '@alfa-client/kommentar'; import { PostfachMailListContainerComponent } from '@alfa-client/postfach'; @@ -30,6 +31,7 @@ import { createEmptyStateResource, createStateResource, } from '@alfa-client/tech-shared'; +import { existsAsHtmlElement, notExistsAsHtmlElement } from '@alfa-client/test-utils'; import { ExpansionPanelComponent, OzgcloudStrokedButtonWithSpinnerComponent, @@ -57,6 +59,7 @@ describe('VorgangDetailAreaComponent', () => { let component: VorgangDetailAreaComponent; let fixture: ComponentFixture<VorgangDetailAreaComponent>; + const collaborationContainer: string = getDataTestIdOf('collaboration-in-voragng-container'); const wiedervorlagenContainer: string = getDataTestIdOf('wiedervorlagen-container-in-vorgang'); const kommentarContainer: string = getDataTestIdOf('kommentar-container-in-vorgang'); const postfachNachrichtenContainer: string = getDataTestIdOf( @@ -86,6 +89,7 @@ describe('VorgangDetailAreaComponent', () => { MockComponent(VorgangForwardingContainerComponent), MockComponent(BescheidListInVorgangContainerComponent), MockComponent(ExpansionPanelComponent), + MockComponent(CollaborationInVorgangContainerComponent), ], }).compileComponents(); }); @@ -101,6 +105,26 @@ describe('VorgangDetailAreaComponent', () => { expect(component).toBeTruthy(); }); + describe('Collaboration', () => { + it('should be visibile if link is present', () => { + component.vorgangStateResource = createStateResource( + createVorgangWithEingangResource([VorgangWithEingangLinkRel.COLLABORATIONS]), + ); + + fixture.detectChanges(); + + existsAsHtmlElement(fixture, collaborationContainer); + }); + + it('should be hidden if link is missing', () => { + component.vorgangStateResource = createStateResource(vorgang); + + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, collaborationContainer); + }); + }); + describe('wiedervorlagen', () => { it('should be visible', () => { component.vorgangStateResource = createStateResource( diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.ts index 84620549535e2efd0a84a992fd9f239f18093fa5..f6a3f401d927e1350cb0903df7cc30ef6a5fa84e 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-area/vorgang-detail-area.component.ts @@ -33,7 +33,7 @@ import { Component, Input } from '@angular/core'; selector: 'alfa-vorgang-detail-area', templateUrl: './vorgang-detail-area.component.html', styleUrls: ['./vorgang-detail-area.component.scss'], - styles: [':host {@apply relative flex flex-row grow}'], + styles: [':host {@apply relative w-full}'], }) export class VorgangDetailAreaComponent { @Input() vorgangStateResource: StateResource<VorgangWithEingangResource>; diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts index 22332fc5187c9ce1bc318ceb77b4b7ad0e30e833..b97073aba59db97bc5c523bfb34b83155061bf7a 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-result/vorgang-detail-bescheiden-result.component.spec.ts @@ -413,7 +413,7 @@ describe('VorgangDetailBescheidenResultComponent', () => { }); it('should call formservice submit', () => { - component.doUpdateAndSend(bescheidDraft, jest.fn()).pipe(first()).subscribe(); + component.doUpdateAndSend(bescheidDraft, jest.fn()).subscribe(); expect(formService.submit).toHaveBeenCalled(); }); @@ -607,7 +607,7 @@ describe('VorgangDetailBescheidenResultComponent', () => { describe('get active step', () => { it('should call formService', () => { - component.getActiveStep().pipe(first()).subscribe(); + component.getActiveStep().subscribe(); expect(formService.getActiveStep).toHaveBeenCalled(); }); @@ -617,7 +617,7 @@ describe('VorgangDetailBescheidenResultComponent', () => { (step: number) => { formService.getActiveStep.mockReturnValue(of(step)); - component.getActiveStep().pipe(first()).subscribe(); + component.getActiveStep().subscribe(); expect(bescheidService.clearAttachmentUpload).toHaveBeenCalled(); }, @@ -633,7 +633,7 @@ describe('VorgangDetailBescheidenResultComponent', () => { (step: number) => { formService.getActiveStep.mockReturnValue(of(step)); - component.getActiveStep().pipe(first()).subscribe(); + component.getActiveStep().subscribe(); expect(component.resetSend).toHaveBeenCalled(); }, @@ -642,7 +642,7 @@ describe('VorgangDetailBescheidenResultComponent', () => { it('should not be called on last step', () => { formService.getActiveStep.mockReturnValue(of(3)); - component.getActiveStep().pipe(first()).subscribe(); + component.getActiveStep().subscribe(); expect(component.resetSend).not.toHaveBeenCalled(); }); @@ -656,7 +656,6 @@ describe('VorgangDetailBescheidenResultComponent', () => { component.resetSend(); component.saveAndSendInProgress$ - .pipe(first()) .subscribe((saveAndSendInProgress: StateResource<CommandResource>) => { expect(saveAndSendInProgress).toEqual(createEmptyStateResource()); done(); diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html index ad1704f9c19a82c2a3fe30cb866eb98730f4ff69..22c3e14619ff5c4636838a058b8e614684b64a83 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html @@ -14,7 +14,7 @@ value="false" data-test-id="button-abgelehnt" variant="bescheid_abgelehnt" - ><ods-close-icon size="medium" class="fill-abgelehnt"></ods-close-icon> + ><ods-close-icon size="large" class="fill-abgelehnt"></ods-close-icon> </ods-radio-button-card> </div> <div class="flex w-full"> diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail.module.ts b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail.module.ts index 5a6d84231e9b41aa3bc51655315edd6ff5e4e531..4ab436286109e6af58e8fa0d851edf0d074ee1f7 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail.module.ts +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail.module.ts @@ -23,6 +23,7 @@ */ import { BescheidModule } from '@alfa-client/bescheid'; import { BinaryFileModule } from '@alfa-client/binary-file'; +import { CollaborationModule } from '@alfa-client/collaboration'; import { ForwardingModule } from '@alfa-client/forwarding'; import { HistorieModule } from '@alfa-client/historie'; import { KommentarModule } from '@alfa-client/kommentar'; @@ -161,6 +162,7 @@ const routes: Routes = [ TextareaEditorComponent, BescheidStatusTextComponent, ErrorMessageComponent, + CollaborationModule, ], declarations: [ VorgangDetailPageComponent, diff --git a/alfa-client/libs/vorgang-shared/src/lib/vorgang.linkrel.ts b/alfa-client/libs/vorgang-shared/src/lib/vorgang.linkrel.ts index 8805fedd51dd0c1b6490f74e09df4346f6158900..a06768b2ed6d34e4c9a6e3fd4f7352a6de6edc89 100644 --- a/alfa-client/libs/vorgang-shared/src/lib/vorgang.linkrel.ts +++ b/alfa-client/libs/vorgang-shared/src/lib/vorgang.linkrel.ts @@ -64,6 +64,10 @@ export enum VorgangWithEingangLinkRel { BESCHEIDE = 'bescheide', UEBERSPRINGEN_UND_ABSCHLIESSEN = 'ueberspringen_und_abschliessen', DOWNLOAD_ATTACHMENTS = 'downloadAttachments', + + SEARCH_ORGANISATIONS_EINHEIT = 'searchOrganisationsEinheit', + + COLLABORATIONS = 'collaborations', } export enum LoeschAnforderungLinkRel { diff --git a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-list-item.component.spec.ts b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-list-item.component.spec.ts index 4a06bce8341af9c60b88bb268fa5c6e437466e40..13b574ef5c2fcdc6d993803208437cb01bb5ce3c 100644 --- a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-list-item.component.spec.ts +++ b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-list-item.component.spec.ts @@ -21,23 +21,12 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { - convertForDataTest, - ConvertForDataTestPipe, - EnumToLabelPipe, - HasLinkPipe, - ToResourceUriPipe, -} from '@alfa-client/tech-shared'; +import { convertForDataTest, ConvertForDataTestPipe, EnumToLabelPipe, HasLinkPipe, ToResourceUriPipe } from '@alfa-client/tech-shared'; import { getElementFromFixture } from '@alfa-client/test-utils'; import { PostfachIconComponent } from '@alfa-client/ui'; import { UserProfileInVorgangListItemContainerComponent } from '@alfa-client/user-profile'; import { VorgangHeaderLinkRel } from '@alfa-client/vorgang-shared'; -import { - AktenzeichenComponent, - VorgangNummerComponent, - VorgangStatusDotComponent, - VorgangStatusTextComponent, -} from '@alfa-client/vorgang-shared-ui'; +import { AktenzeichenComponent, VorgangNummerComponent, VorgangStatusDotComponent, VorgangStatusTextComponent } from '@alfa-client/vorgang-shared-ui'; import { WiedervorlageListInVorgangListContainerComponent } from '@alfa-client/wiedervorlage'; import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; @@ -55,7 +44,7 @@ import { MockComponent, MockModule } from 'ng-mocks'; import { VorgangBescheidStatusComponent } from './vorgang-bescheid-status/vorgang-bescheid-status.component'; import { VorgangCreatedAtComponent } from './vorgang-created-at/vorgang-created-at.component'; import { VorgangListItemComponent } from './vorgang-list-item.component'; -import { VorgangNextFristButton } from './vorgang-next-frist-button/vorgang-next-frist-button.component'; +import { VorgangNextFristButtonComponent } from './vorgang-next-frist-button/vorgang-next-frist-button.component'; registerLocaleData(localeDe, 'de', localeDeExtra); @@ -83,7 +72,7 @@ describe('VorgangListItemComponent', () => { MockComponent(VorgangStatusDotComponent), MockComponent(VorgangStatusTextComponent), MockComponent(WiedervorlageListInVorgangListContainerComponent), - MockComponent(VorgangNextFristButton), + MockComponent(VorgangNextFristButtonComponent), MockComponent(UserProfileInVorgangListItemContainerComponent), MockComponent(VorgangCreatedAtComponent), MockComponent(VorgangBescheidStatusComponent), diff --git a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.spec.ts b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.spec.ts index fdb11dc41d8f88b720e77c67589bbbce34e1e0f6..78c2a7a5031a73a342aac9438339a7a19b38c420 100644 --- a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.spec.ts +++ b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.spec.ts @@ -21,31 +21,31 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { FormatToPrettyDatePipe, HasLinkPipe } from '@alfa-client/tech-shared'; +import { VorgangHeaderLinkRel } from '@alfa-client/vorgang-shared'; import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeDeExtra from '@angular/common/locales/extra/de'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatIcon } from '@angular/material/icon'; -import { FormatToPrettyDatePipe, HasLinkPipe } from '@alfa-client/tech-shared'; -import { VorgangHeaderLinkRel } from '@alfa-client/vorgang-shared'; import { WiedervorlageIconComponent } from 'libs/vorgang-shared-ui/src/lib/wiedervorlage-icon/wiedervorlage-icon.component'; import { createVorgangResource } from 'libs/vorgang-shared/test/vorgang'; import { WiedervorlageListInVorgangListContainerComponent } from 'libs/wiedervorlage/src/lib/wiedervorlage-list-in-vorgang-list-container/wiedervorlage-list-in-vorgang-list-container.component'; import { MockComponent } from 'ng-mocks'; -import { VorgangNextFristButton } from './vorgang-next-frist-button.component'; +import { VorgangNextFristButtonComponent } from './vorgang-next-frist-button.component'; import * as dateUtil from '../../../../../../../tech-shared/src/lib/date.util'; registerLocaleData(localeDe, 'de', localeDeExtra); -describe('VorgangNextFristButton', () => { - let component: VorgangNextFristButton; - let fixture: ComponentFixture<VorgangNextFristButton>; +describe('VorgangNextFristButtonComponent', () => { + let component: VorgangNextFristButtonComponent; + let fixture: ComponentFixture<VorgangNextFristButtonComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ - VorgangNextFristButton, + VorgangNextFristButtonComponent, FormatToPrettyDatePipe, HasLinkPipe, MatIcon, @@ -56,7 +56,7 @@ describe('VorgangNextFristButton', () => { }); beforeEach(() => { - fixture = TestBed.createComponent(VorgangNextFristButton); + fixture = TestBed.createComponent(VorgangNextFristButtonComponent); component = fixture.componentInstance; component.vorgang = createVorgangResource([VorgangHeaderLinkRel.VORGANG_WITH_EINGANG]); fixture.detectChanges(); diff --git a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.ts b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.ts index f552a5b278d5b8f2b42e1e04a35eac0a18bbe27c..c85a4ce054c39c3587012bdb9183faa0f3628a8e 100644 --- a/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.ts +++ b/alfa-client/libs/vorgang/src/lib/vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component.ts @@ -21,23 +21,21 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { Component, Input, OnInit } from '@angular/core'; import { isISODateInPast } from '@alfa-client/tech-shared'; -import { VorgangHeaderLinkRel, VorgangResource } from '@alfa-client/vorgang-shared'; +import { VorgangResource } from '@alfa-client/vorgang-shared'; +import { Component, Input, OnInit } from '@angular/core'; @Component({ selector: 'alfa-vorgang-next-frist-button', templateUrl: './vorgang-next-frist-button.component.html', styleUrls: ['./vorgang-next-frist-button.component.scss'], }) -export class VorgangNextFristButton implements OnInit { +export class VorgangNextFristButtonComponent implements OnInit { @Input() vorgang: VorgangResource; public showWiedervorlagen: boolean = false; public isOverdue: boolean; - readonly vorgangLinkRel = VorgangHeaderLinkRel; - ngOnInit() { this.isOverdue = isISODateInPast(this.vorgang.nextFrist as unknown as string); } diff --git a/alfa-client/libs/vorgang/src/lib/vorgang.module.ts b/alfa-client/libs/vorgang/src/lib/vorgang.module.ts index 073eeb74eb9eb31e3b8c5bee5448438d2e810ffd..0fea0e60e669a1a394c27127d2573e64a21f8f3c 100644 --- a/alfa-client/libs/vorgang/src/lib/vorgang.module.ts +++ b/alfa-client/libs/vorgang/src/lib/vorgang.module.ts @@ -39,7 +39,7 @@ import { EmptyListComponent } from './vorgang-list-container/vorgang-list/empty- import { VorgangBescheidStatusComponent } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-bescheid-status/vorgang-bescheid-status.component'; import { VorgangCreatedAtComponent } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-created-at/vorgang-created-at.component'; import { VorgangListItemComponent } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-list-item.component'; -import { VorgangNextFristButton } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component'; +import { VorgangNextFristButtonComponent } from './vorgang-list-container/vorgang-list/vorgang-list-item/vorgang-next-frist-button/vorgang-next-frist-button.component'; import { VorgangListComponent } from './vorgang-list-container/vorgang-list/vorgang-list.component'; import { VorgangListPageContainerComponent } from './vorgang-list-page-container/vorgang-list-page-container.component'; import { VorgangFilterMenuContainerComponent } from './vorgang-list-page-container/vorgang-list-page/vorgang-filter-menu-container/vorgang-filter-menu-container.component'; @@ -184,7 +184,7 @@ const routes: Routes = [ VorgangListContainerComponent, VorgangListPageComponent, EmptyListComponent, - VorgangNextFristButton, + VorgangNextFristButtonComponent, VorgangListPageContainerComponent, VorgangFilterMenuContainerComponent, VorgangFilterMenuComponent, diff --git a/alfa-client/package-lock.json b/alfa-client/package-lock.json index a7e9f6cfe77a4da3de7f151bdcbd299486c59c20..b551b3e5a341ab04c060b67cf3b84a262f0e74d4 100644 --- a/alfa-client/package-lock.json +++ b/alfa-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "alfa", - "version": "0.7.0-SNAPSHOT", + "version": "1.0.0-SNAPSHOT", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "alfa", - "version": "0.7.0-SNAPSHOT", + "version": "1.0.0-SNAPSHOT", "license": "MIT", "dependencies": { "@angular/animations": "17.3.10", @@ -202,13 +202,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1800.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@angular-devkit/architect/-/architect-0.1800.3.tgz", - "integrity": "sha512-ZoQuvCN/Ft4XJ+/XouYFKGoyEYTfZ8I5yI1M4t19lkRb3MwpQribWcZu4PP+SNnS6/9qnW7guxiQGS+CVlqnDg==", + "version": "0.1801.4", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@angular-devkit/architect/-/architect-0.1801.4.tgz", + "integrity": "sha512-Ch1ZwRh1N/vcCKHm4ErLcgZly3tlwdLUDGBaAIlhE3YFGq543Swv6a5IcDw0veD6iGFceJAmbrp+z5hmzI8p5A==", "dev": true, "peer": true, "dependencies": { - "@angular-devkit/core": "18.0.3", + "@angular-devkit/core": "18.1.4", "rxjs": "7.8.1" }, "engines": { @@ -218,15 +218,15 @@ } }, "node_modules/@angular-devkit/architect/node_modules/@angular-devkit/core": { - "version": "18.0.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@angular-devkit/core/-/core-18.0.3.tgz", - "integrity": "sha512-nTs1KbNSVCVooPdDaeTh1YbggNVaqexbQjXNIvJJzRB8qPkWNPxm0pQeFjU7kWUBg2+aBXN4/CNwU1YHwxfiSQ==", + "version": "18.1.4", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@angular-devkit/core/-/core-18.1.4.tgz", + "integrity": "sha512-lKBsvbqW2QFL8terzNuSDSmKBo8//QNRO4qU5mVJ1fFf4xBJanXKoiAMuADhx+/owVIptnYT59IZ8jUAna+Srg==", "dev": true, "peer": true, "dependencies": { - "ajv": "8.13.0", + "ajv": "8.16.0", "ajv-formats": "3.0.1", - "jsonc-parser": "3.2.1", + "jsonc-parser": "3.3.1", "picomatch": "4.0.2", "rxjs": "7.8.1", "source-map": "0.7.4" @@ -246,9 +246,9 @@ } }, "node_modules/@angular-devkit/architect/node_modules/ajv": { - "version": "8.13.0", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "version": "8.16.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/ajv/-/ajv-8.16.0.tgz", + "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", "dev": true, "peer": true, "dependencies": { @@ -280,6 +280,13 @@ } } }, + "node_modules/@angular-devkit/architect/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "peer": true + }, "node_modules/@angular-devkit/architect/node_modules/picomatch": { "version": "4.0.2", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/picomatch/-/picomatch-4.0.2.tgz", @@ -6276,30 +6283,30 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.2", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/core/-/core-1.6.2.tgz", - "integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==", + "version": "1.6.7", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/core/-/core-1.6.7.tgz", + "integrity": "sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==", "dev": true, "peer": true, "dependencies": { - "@floating-ui/utils": "^0.2.0" + "@floating-ui/utils": "^0.2.7" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.5", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/dom/-/dom-1.6.5.tgz", - "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==", + "version": "1.6.10", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/dom/-/dom-1.6.10.tgz", + "integrity": "sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==", "dev": true, "peer": true, "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.7" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.0", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/react-dom/-/react-dom-2.1.0.tgz", - "integrity": "sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==", + "version": "2.1.1", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", + "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", "dev": true, "peer": true, "dependencies": { @@ -6311,9 +6318,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.2", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/utils/-/utils-0.2.2.tgz", - "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==", + "version": "0.2.7", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@floating-ui/utils/-/utils-0.2.7.tgz", + "integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==", "dev": true, "peer": true }, @@ -8465,13 +8472,13 @@ } }, "node_modules/@nrwl/devkit": { - "version": "19.2.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@nrwl/devkit/-/devkit-19.2.3.tgz", - "integrity": "sha512-OL6sc70gR/USasvbYzyYY44Hd5ZCde2UfiA5h8VeAYAJbq+JmtscpvjcnZ7OIsXyYEOxe1rypULElqu/8qpKzQ==", + "version": "19.5.7", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@nrwl/devkit/-/devkit-19.5.7.tgz", + "integrity": "sha512-sTEwqsAT6bMturU14o/0O6v509OkwGOglxpbiL/zIYO/fDkMoNgnhlHBIT87i4YVuofMz2Z+hTfjDskzDPRSYw==", "dev": true, "peer": true, "dependencies": { - "@nx/devkit": "19.2.3" + "@nx/devkit": "19.5.7" } }, "node_modules/@nrwl/eslint-plugin-nx": { @@ -8985,13 +8992,13 @@ } }, "node_modules/@nx/devkit": { - "version": "19.2.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@nx/devkit/-/devkit-19.2.3.tgz", - "integrity": "sha512-if1WwRVexrQBBADObEcxVIivq4QRZWY/nYRhCQy/qfFI6Cu2jBSI6ZQ1uy7to2L2sQPLgn8v2beQZiAeZdIktg==", + "version": "19.5.7", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@nx/devkit/-/devkit-19.5.7.tgz", + "integrity": "sha512-mUtZQcdqbF0Q9HfyG14jmpPCtZ1GnVaLNIADZv5SLpFyfh4ZjaBw6wdjPj7Sp3imLoyqMrcd9nCRNO2hlem8bw==", "dev": true, "peer": true, "dependencies": { - "@nrwl/devkit": "19.2.3", + "@nrwl/devkit": "19.5.7", "ejs": "^3.1.7", "enquirer": "~2.3.6", "ignore": "^5.0.4", @@ -11055,28 +11062,61 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.0.4", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", - "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", "dev": true, "peer": true, "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-collection": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-controllable-state": "1.0.1" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "dev": true, + "peer": true + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -11087,6 +11127,167 @@ } } }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "1.2.2", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-select/-/react-select-1.2.2.tgz", @@ -11133,20 +11334,59 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.0.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", - "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", + "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", "dev": true, "peer": true, "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" + "@radix-ui/react-primitive": "2.0.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -11157,6 +11397,25 @@ } } }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.0.2", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", @@ -11177,22 +11436,21 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.0.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", - "integrity": "sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==", + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", + "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", "dev": true, "peer": true, "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-controllable-state": "1.0.1" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -11204,26 +11462,25 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.0.4", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz", - "integrity": "sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==", + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", + "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", "dev": true, "peer": true, "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-roving-focus": "1.0.4", - "@radix-ui/react-toggle": "1.0.3", - "@radix-ui/react-use-controllable-state": "1.0.1" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-toggle": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -11234,27 +11491,260 @@ } } }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "dev": true, + "peer": true + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "dev": true, + "peer": true + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.0.4", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toolbar/-/react-toolbar-1.0.4.tgz", - "integrity": "sha512-tBgmM/O7a07xbaEkYJWYTXkIdU/1pW4/KZORR43toC/4XWyBCURK0ei9kMUdp+gTPPKBgYLxXmRSH1EVcIDp8Q==", + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-toolbar/-/react-toolbar-1.1.0.tgz", + "integrity": "sha512-ZUKknxhMTL/4hPh+4DuaTot9aO7UD6Kupj4gqXCsBTayX1pD1L+0C2/2VZKXb4tIifQklZ3pf2hG9T+ns+FclQ==", "dev": true, "peer": true, "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-roving-focus": "1.0.4", - "@radix-ui/react-separator": "1.0.3", - "@radix-ui/react-toggle-group": "1.0.4" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-separator": "1.1.0", + "@radix-ui/react-toggle-group": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -11265,6 +11755,104 @@ } } }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "dev": true, + "peer": true + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "dev": true, + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dev": true, + "peer": true, + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", @@ -13612,19 +14200,19 @@ } }, "node_modules/@storybook/components": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/components/-/components-7.6.19.tgz", - "integrity": "sha512-8Zw/RQ4crzKkUR7ojxvRIj8vktKiBBO8Nq93qv4JfDqDWrcR7cro0hOlZgmZmrzbFunBBt6WlsNNO6nVP7R4Xw==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/components/-/components-7.6.20.tgz", + "integrity": "sha512-0d8u4m558R+W5V+rseF/+e9JnMciADLXTpsILrG+TBhwECk0MctIWW18bkqkujdCm8kDZr5U2iM/5kS1Noy7Ug==", "dev": true, "peer": true, "dependencies": { "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-toolbar": "^1.0.4", - "@storybook/client-logger": "7.6.19", + "@storybook/client-logger": "7.6.20", "@storybook/csf": "^0.1.2", "@storybook/global": "^5.0.0", - "@storybook/theming": "7.6.19", - "@storybook/types": "7.6.19", + "@storybook/theming": "7.6.20", + "@storybook/types": "7.6.20", "memoizerific": "^1.11.3", "use-resize-observer": "^9.1.0", "util-deprecate": "^1.0.2" @@ -13639,14 +14227,14 @@ } }, "node_modules/@storybook/components/node_modules/@storybook/channels": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/channels/-/channels-7.6.19.tgz", - "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/channels/-/channels-7.6.20.tgz", + "integrity": "sha512-4hkgPSH6bJclB2OvLnkZOGZW1WptJs09mhQ6j6qLjgBZzL/ZdD6priWSd7iXrmPiN5TzUobkG4P4Dp7FjkiO7A==", "dev": true, "peer": true, "dependencies": { - "@storybook/client-logger": "7.6.19", - "@storybook/core-events": "7.6.19", + "@storybook/client-logger": "7.6.20", + "@storybook/core-events": "7.6.20", "@storybook/global": "^5.0.0", "qs": "^6.10.0", "telejson": "^7.2.0", @@ -13658,9 +14246,9 @@ } }, "node_modules/@storybook/components/node_modules/@storybook/client-logger": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/client-logger/-/client-logger-7.6.19.tgz", - "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/client-logger/-/client-logger-7.6.20.tgz", + "integrity": "sha512-NwG0VIJQCmKrSaN5GBDFyQgTAHLNishUPLW1NrzqTDNAhfZUoef64rPQlinbopa0H4OXmlB+QxbQIb3ubeXmSQ==", "dev": true, "peer": true, "dependencies": { @@ -13672,13 +14260,13 @@ } }, "node_modules/@storybook/components/node_modules/@storybook/types": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/types/-/types-7.6.19.tgz", - "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/types/-/types-7.6.20.tgz", + "integrity": "sha512-GncdY3x0LpbhmUAAJwXYtJDUQEwfF175gsjH0/fxPkxPoV7Sef9TM41jQLJW/5+6TnZoCZP/+aJZTJtq3ni23Q==", "dev": true, "peer": true, "dependencies": { - "@storybook/channels": "7.6.19", + "@storybook/channels": "7.6.20", "@types/babel__core": "^7.0.0", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" @@ -13853,9 +14441,9 @@ } }, "node_modules/@storybook/core-events": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/core-events/-/core-events-7.6.19.tgz", - "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/core-events/-/core-events-7.6.20.tgz", + "integrity": "sha512-tlVDuVbDiNkvPDFAu+0ou3xBBYbx9zUURQz4G9fAq0ScgBOs/bpzcRrFb4mLpemUViBAd47tfZKdH4MAX45KVQ==", "dev": true, "peer": true, "dependencies": { @@ -14383,14 +14971,14 @@ } }, "node_modules/@storybook/theming": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/theming/-/theming-7.6.19.tgz", - "integrity": "sha512-sAho13MmtA80ctOaLn8lpkQBsPyiqSdLcOPH5BWFhatQzzBQCpTAKQk+q/xGju8bNiPZ+yQBaBzbN8SfX8ceCg==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/theming/-/theming-7.6.20.tgz", + "integrity": "sha512-iT1pXHkSkd35JsCte6Qbanmprx5flkqtSHC6Gi6Umqoxlg9IjiLPmpHbaIXzoC06DSW93hPj5Zbi1lPlTvRC7Q==", "dev": true, "peer": true, "dependencies": { "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@storybook/client-logger": "7.6.19", + "@storybook/client-logger": "7.6.20", "@storybook/global": "^5.0.0", "memoizerific": "^1.11.3" }, @@ -14404,9 +14992,9 @@ } }, "node_modules/@storybook/theming/node_modules/@storybook/client-logger": { - "version": "7.6.19", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/client-logger/-/client-logger-7.6.19.tgz", - "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "version": "7.6.20", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@storybook/client-logger/-/client-logger-7.6.20.tgz", + "integrity": "sha512-NwG0VIJQCmKrSaN5GBDFyQgTAHLNishUPLW1NrzqTDNAhfZUoef64rPQlinbopa0H4OXmlB+QxbQIb3ubeXmSQ==", "dev": true, "peer": true, "dependencies": { @@ -14675,6 +15263,16 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "devOptional": true }, + "node_modules/@swc/helpers": { + "version": "0.5.12", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@swc/helpers/-/helpers-0.5.12.tgz", + "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@swc/types": { "version": "0.1.8", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/@swc/types/-/types-0.1.8.tgz", @@ -19867,9 +20465,9 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "dependencies": { "ms": "2.1.2" }, @@ -27768,32 +28366,32 @@ } }, "node_modules/mocha": { - "version": "10.4.0", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/mocha/-/mocha-10.4.0.tgz", - "integrity": "sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==", + "version": "10.7.0", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/mocha/-/mocha-10.7.0.tgz", + "integrity": "sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA==", "dev": true, "peer": true, "dependencies": { - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.4", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "8.1.0", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "5.0.1", - "ms": "2.1.3", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "workerpool": "6.2.1", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" }, "bin": { "_mocha": "bin/_mocha", @@ -27803,16 +28401,6 @@ "node": ">= 14.0.0" } }, - "node_modules/mocha/node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/mocha/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -27839,34 +28427,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/mocha/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/cliui/-/cliui-7.0.4.tgz", @@ -27879,16 +28439,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/mocha/node_modules/diff": { - "version": "5.0.0", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -27934,9 +28484,9 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.0.1", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "version": "5.1.6", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "peer": true, "dependencies": { @@ -27953,16 +28503,6 @@ "dev": true, "peer": true }, - "node_modules/mocha/node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "dev": true, - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/mocha/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -28030,9 +28570,9 @@ } }, "node_modules/mocha/node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "version": "20.2.9", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "peer": true, "engines": { @@ -37868,9 +38408,9 @@ } }, "node_modules/workerpool": { - "version": "6.2.1", - "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/workerpool/-/workerpool-6.2.1.tgz", - "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "version": "6.5.1", + "resolved": "http://nexus.ozg-sh.de/repository/npm-proxy/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", "dev": true, "peer": true }, diff --git a/alfa-client/package.json b/alfa-client/package.json index 730267babe4d51dff0fb89982e5edf16f65f0631..e1523dd25e7b7786a77adf6d0fe52760d69516fe 100644 --- a/alfa-client/package.json +++ b/alfa-client/package.json @@ -1,6 +1,6 @@ { "name": "alfa", - "version": "0.8.0-SNAPSHOT", + "version": "1.0.0-SNAPSHOT", "license": "MIT", "scripts": { "start": "nx run alfa:serve --port 4300 --disable-host-check", @@ -10,9 +10,10 @@ "start-for-screenreader": "nx run alfa:serve --host 192.168.178.20 --port 4300 --disable-host-check --verbose", "start:devbe": "nx run alfa:serve --port 4300 --disable-host-check --proxy-config proxy.dev.conf.json --verbose", "build": "nx run alfa:build", + "ci-build-alfa-client-container": "nx container alfa", "test": "nx affected --target=test --parallel 8 -- --runInBand", "test:cov": "jest --coverage", - "test:lib": "nx test ${npm_config_lib} --watchAll", + "test:lib": "nx test ${npm_config_lib}", "test:debug:lib": "nx test ${npm_config_lib} --detectOpenHandles --watchAll", "ci-build": "nx run alfa:build --outputHashing=all", "ci-build-admin": "nx container admin && cp -r dist/ apps/admin/", @@ -161,4 +162,4 @@ "ts-node": "10.9.1", "typescript": "5.4.5" } -} +} \ No newline at end of file diff --git a/alfa-client/pom.xml b/alfa-client/pom.xml index b36055f368b4404644459bb3f3816469ba738293..576dd79ddd4c0ab6fb8adbf86fcb0e631b3aab55 100644 --- a/alfa-client/pom.xml +++ b/alfa-client/pom.xml @@ -29,7 +29,7 @@ <parent> <groupId>de.ozgcloud.alfa</groupId> <artifactId>alfa</artifactId> - <version>2.12.0-SNAPSHOT</version> + <version>2.13.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> diff --git a/alfa-client/tsconfig.base.json b/alfa-client/tsconfig.base.json index b8645da774def8753885d8a14cfd87a1e8f887eb..15284c9f0e8bc51994ae4e9b965a16c8b17d0bb6 100644 --- a/alfa-client/tsconfig.base.json +++ b/alfa-client/tsconfig.base.json @@ -23,6 +23,8 @@ "@alfa-client/bescheid-shared": ["libs/bescheid-shared/src/index.ts"], "@alfa-client/binary-file": ["libs/binary-file/src/index.ts"], "@alfa-client/binary-file-shared": ["libs/binary-file-shared/src/index.ts"], + "@alfa-client/collaboration": ["libs/collaboration/src/index.ts"], + "@alfa-client/collaboration-shared": ["libs/collaboration-shared/src/index.ts"], "@alfa-client/command-shared": ["libs/command-shared/src/index.ts"], "@alfa-client/environment-shared": ["libs/environment-shared/src/index.ts"], "@alfa-client/forwarding": ["libs/forwarding/src/index.ts"], @@ -55,7 +57,8 @@ "@alfa-client/wiedervorlage-shared": ["libs/wiedervorlage-shared/src/index.ts"], "@ods/component": ["libs/design-component/src/index.ts"], "@ods/system": ["libs/design-system/src/index.ts"], - "authentication": ["libs/authentication/src/index.ts"] + "authentication": ["libs/authentication/src/index.ts"], + "test": ["libs/test/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/alfa-server/pom.xml b/alfa-server/pom.xml index e157390eee8d5e6722072745abd75cb760f831ec..352336b4ac3074f81a411207f393d1b590933cbf 100644 --- a/alfa-server/pom.xml +++ b/alfa-server/pom.xml @@ -5,7 +5,7 @@ <parent> <groupId>de.ozgcloud.alfa</groupId> <artifactId>alfa</artifactId> - <version>2.12.0-SNAPSHOT</version> + <version>2.13.0-SNAPSHOT</version> </parent> <artifactId>alfa-server</artifactId> diff --git a/alfa-server/src/main/resources/application-dev.yml b/alfa-server/src/main/resources/application-dev.yml index 52ca56a22d8fa5ded4dcecfae01b3e22553c2da1..6f11904235f17602539afaca47cd695e69deee8b 100644 --- a/alfa-server/src/main/resources/application-dev.yml +++ b/alfa-server/src/main/resources/application-dev.yml @@ -10,6 +10,7 @@ server: ozgcloud: feature: reply-always-allowed: true + collaboration-enabled: true production: false stage: production: false diff --git a/alfa-server/src/main/resources/application-e2e.yml b/alfa-server/src/main/resources/application-e2e.yml index 585c3a5f701bd2f11ccc56211468786f077bfcd4..69d9b46a198e57b5d03f01cc78f2d83e209f9335 100644 --- a/alfa-server/src/main/resources/application-e2e.yml +++ b/alfa-server/src/main/resources/application-e2e.yml @@ -9,6 +9,7 @@ keycloak: ozgcloud: feature: reply-always-allowed: true + collaboration-enabled: true forwarding: lninfo: url: classpath:files/LandesnetzInfo.html diff --git a/alfa-server/src/main/resources/application-local.yml b/alfa-server/src/main/resources/application-local.yml index b113a7052d27746ebf67fdd3600b381e7e658dd5..74743fd293aab8aeda88d286548ff33e73ea55d7 100644 --- a/alfa-server/src/main/resources/application-local.yml +++ b/alfa-server/src/main/resources/application-local.yml @@ -18,6 +18,7 @@ grpc: ozgcloud: feature: reply-always-allowed: true + collaboration-enabled: true production: false user-assistance: documentation: diff --git a/alfa-server/src/main/resources/application.yml b/alfa-server/src/main/resources/application.yml index ac930de36a83c9b7d962e72dca5ef29eae2fdd57..0f0c8e24b2ea7023c57f11e22b8f3d7c997864e3 100644 --- a/alfa-server/src/main/resources/application.yml +++ b/alfa-server/src/main/resources/application.yml @@ -61,6 +61,9 @@ grpc: user-manager: address: static://127.0.0.1:9000 negotiationType: TLS + zufi-manager: + address: static://127.0.0.1:9190 + negotiationType: TLS ozgcloud: auth: diff --git a/alfa-service/pom.xml b/alfa-service/pom.xml index 4d4e6c9ae8b0eca82e0af3bf544e12e11dc7fa15..24d69e9a5b0678baaea9c632062d636ba513d436 100644 --- a/alfa-service/pom.xml +++ b/alfa-service/pom.xml @@ -24,14 +24,16 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>de.ozgcloud.alfa</groupId> <artifactId>alfa</artifactId> - <version>2.12.0-SNAPSHOT</version> + <version>2.13.0-SNAPSHOT</version> </parent> <artifactId>alfa-service</artifactId> @@ -127,6 +129,10 @@ <groupId>de.ozgcloud.user</groupId> <artifactId>user-manager-interface</artifactId> </dependency> + <dependency> + <groupId>de.ozgcloud.zufi</groupId> + <artifactId>zufi-manager-interface</artifactId> + </dependency> <!-- tools --> <dependency> @@ -229,4 +235,4 @@ </plugins> </build> -</project> +</project> \ No newline at end of file diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Anschrift.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Anschrift.java new file mode 100644 index 0000000000000000000000000000000000000000..e39bea0b531da3fa6d82b4a122f226135714d96f --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Anschrift.java @@ -0,0 +1,15 @@ +package de.ozgcloud.alfa.collaboration; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +class Anschrift { + + private String strasse; + private String hausnummer; + private String plz; + private String ort; + +} \ No newline at end of file diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Collaboration.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Collaboration.java new file mode 100644 index 0000000000000000000000000000000000000000..a6e8f794e96951c0eb215b97dc73b49205ce0234 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/Collaboration.java @@ -0,0 +1,24 @@ +package de.ozgcloud.alfa.collaboration; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import de.ozgcloud.alfa.common.LinkedResource; +import de.ozgcloud.alfa.common.command.CommandBody; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class Collaboration implements CommandBody { + + @JsonIgnore + private String vorgangId; + @JsonIgnore + private String collaborationVorgangId; + + private String titel; + private String anfrage; + + @LinkedResource(controllerClass = OrganisationsEinheitController.class) + private String zustaendigeStelle; +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationController.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationController.java new file mode 100644 index 0000000000000000000000000000000000000000..63a1fa8edc08547408395b4b70739cd766ef27fe --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationController.java @@ -0,0 +1,26 @@ +package de.ozgcloud.alfa.collaboration; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping(CollaborationController.PATH) +@RequiredArgsConstructor +public class CollaborationController { + + private final CollaborationModelAssembler assembler; + private final CollaborationService service; + + static final String PATH = "/api/vorgangs"; // NOSONAR + + @GetMapping("/{vorgangId}/collaborations") + public CollectionModel<EntityModel<Collaboration>> getAllByVorgangId(@PathVariable String vorgangId) { + return assembler.toCollectionModel(service.getCollaborations(vorgangId), vorgangId); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssembler.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssembler.java new file mode 100644 index 0000000000000000000000000000000000000000..0323c66fe4fa72971c00afc34815b8d92a064670 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssembler.java @@ -0,0 +1,48 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import java.util.stream.Stream; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import de.ozgcloud.alfa.common.ModelBuilder; +import de.ozgcloud.alfa.common.command.CommandController; +import de.ozgcloud.alfa.vorgang.VorgangController; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class CollaborationModelAssembler implements RepresentationModelAssembler<Collaboration, EntityModel<Collaboration>> { + + static final LinkRelation REL_CREATE_COLLABORATION_REQUEST = LinkRelation.of("createCollaborationRequest"); + + private final VorgangController vorgangController; + + @Override + public EntityModel<Collaboration> toModel(Collaboration collaboration) { + return ModelBuilder.fromEntity(collaboration) + // TODO: Wenn Schnittstelle zum laden der Collaboration existiert, muss self + // link ergänzt werden + .buildModel(); + } + + public CollectionModel<EntityModel<Collaboration>> toCollectionModel(Stream<? extends Collaboration> entities, String vorgangId) { + var collectionModel = CollectionModel.of(entities.map(this::toModel).toList()) + .add(linkTo(methodOn(CollaborationController.class).getAllByVorgangId(vorgangId)).withSelfRel()); + return collectionModel + .addIf(collectionModel.getContent().isEmpty(), () -> buildCreateCollaborationRequestLink(vorgangId)); + } + + Link buildCreateCollaborationRequestLink(String vorgangId) { + var vorgang = vorgangController.getVorgang(vorgangId); + return linkTo(methodOn(CommandController.CommandByRelationController.class).createCommand(vorgang.getId(), vorgang.getId(), + vorgang.getVersion(), null)).withRel(REL_CREATE_COLLABORATION_REQUEST); + } + +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationRemoteService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationRemoteService.java new file mode 100644 index 0000000000000000000000000000000000000000..a81de9638ea305b333fbebb229520bb0d42ad33c --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationRemoteService.java @@ -0,0 +1,14 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +@Service +public class CollaborationRemoteService { + + public Stream<Collaboration> getCollaborations(String vorgangId) { + // TODO: Replace Dummy Data with real grpc call + return Stream.empty(); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationService.java new file mode 100644 index 0000000000000000000000000000000000000000..8b4406b926453d570d635b13a556ef6e9050b65b --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationService.java @@ -0,0 +1,23 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +class CollaborationService { + + private final CollaborationRemoteService remoteService; + + public Stream<Collaboration> getCollaborations(String vorgangId) { + return remoteService.getCollaborations(vorgangId); + } + + public boolean hasCollaboration(String vorgangId) { + return remoteService.getCollaborations(vorgangId).findAny().isPresent(); + } + +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessor.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessor.java new file mode 100644 index 0000000000000000000000000000000000000000..c8c7378a3ea53c202a34f4cf8582a30e71b8da09 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessor.java @@ -0,0 +1,44 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import java.util.Objects; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.server.RepresentationModelProcessor; +import org.springframework.stereotype.Component; + +import de.ozgcloud.alfa.common.ModelBuilder; +import de.ozgcloud.alfa.common.user.CurrentUserService; +import de.ozgcloud.alfa.common.user.UserRole; +import de.ozgcloud.alfa.vorgang.VorgangWithEingang; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +@ConditionalOnProperty("ozgcloud.feature.collaboration-enabled") +class CollaborationVorgangProcessor implements RepresentationModelProcessor<EntityModel<VorgangWithEingang>> { + + static final LinkRelation REL_COLLABORATIONS = LinkRelation.of("collaborations"); + static final LinkRelation REL_SEARCH_ORGANISATIONS_EINHEIT = LinkRelation.of("searchOrganisationsEinheit"); + + private final CurrentUserService currentUserService; + private final CollaborationService collaborationService; + + @Override + public EntityModel<VorgangWithEingang> process(EntityModel<VorgangWithEingang> model) { + var vorgang = model.getContent(); + + if (Objects.isNull(vorgang) || !currentUserService.hasRole(UserRole.VERWALTUNG_USER)) { + return model; + } + + return ModelBuilder.fromModel(model) + .ifMatch(() -> !collaborationService.hasCollaboration(vorgang.getId())) + .addLink(linkTo(methodOn(OrganisationsEinheitController.class).search(null)).withRel(REL_SEARCH_ORGANISATIONS_EINHEIT)) + .addLink(linkTo(methodOn(CollaborationController.class).getAllByVorgangId(vorgang.getId())).withRel(REL_COLLABORATIONS)) + .buildModel(); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheit.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheit.java new file mode 100644 index 0000000000000000000000000000000000000000..33bbb03f0d3e3ccfb854740dd9d97e9316d0a691 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheit.java @@ -0,0 +1,14 @@ +package de.ozgcloud.alfa.collaboration; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.jackson.Jacksonized; + +@Builder +@Getter +@Jacksonized +class OrganisationsEinheit { + + private String id; + private XzufiId xzufiId; +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitController.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitController.java new file mode 100644 index 0000000000000000000000000000000000000000..e60539a72396755760875f86af415b3cc547dd9d --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitController.java @@ -0,0 +1,37 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.Optional; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping(OrganisationsEinheitController.PATH) +@RequiredArgsConstructor +class OrganisationsEinheitController { + + static final String PATH = "/api/organisationseinheits"; // NOSONAR + static final String SEARCH_BY_PARAM = "searchBy"; + + private final OrganisationsEinheitService service; + private final OrganisationsEinheitModelAssembler assembler; + private final OrganisationsEinheitHeaderModelAssembler headerModelAssembler; + + @GetMapping("/{organisationsEinheitId}") + public ResponseEntity<EntityModel<OrganisationsEinheit>> getById(@PathVariable String organisationsEinheitId) { + return ResponseEntity.of(Optional.of(service.getById(organisationsEinheitId)).map(assembler::toModel)); + } + + @GetMapping(params = { SEARCH_BY_PARAM }) + public CollectionModel<EntityModel<OrganisationsEinheitHeader>> search(@RequestParam String searchBy) { + return headerModelAssembler.toCollectionModel(service.searchOrganisationsEinheiten(searchBy).toList()); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeader.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeader.java new file mode 100644 index 0000000000000000000000000000000000000000..ebe24c6a9a962df8c6438ee52da962871f630254 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeader.java @@ -0,0 +1,17 @@ +package de.ozgcloud.alfa.collaboration; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +class OrganisationsEinheitHeader { + + @JsonIgnore + private String id; + private String name; + private Anschrift anschrift; + +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapper.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..0fa1bb19f6546781d3cc0b3ea4a9b111c09f7be2 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapper.java @@ -0,0 +1,11 @@ +package de.ozgcloud.alfa.collaboration; + +import org.mapstruct.Mapper; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit; + +@Mapper +interface OrganisationsEinheitHeaderMapper { + + OrganisationsEinheitHeader fromGrpc(GrpcOrganisationsEinheit organisationsEinheit); +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssembler.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssembler.java new file mode 100644 index 0000000000000000000000000000000000000000..8cbea84ba7535ce790c4dc8d80333f9d47737319 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssembler.java @@ -0,0 +1,30 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import jakarta.annotation.Nonnull; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import de.ozgcloud.alfa.common.ModelBuilder; + +@Component +class OrganisationsEinheitHeaderModelAssembler + implements RepresentationModelAssembler<OrganisationsEinheitHeader, EntityModel<OrganisationsEinheitHeader>> { + + @Override + public EntityModel<OrganisationsEinheitHeader> toModel(@Nonnull OrganisationsEinheitHeader entity) { + return ModelBuilder.fromEntity(entity) + .addLink(linkTo(OrganisationsEinheitController.class).slash(entity.getId()).withSelfRel()) + .buildModel(); + } + + @Override + public CollectionModel<EntityModel<OrganisationsEinheitHeader>> toCollectionModel(Iterable<? extends OrganisationsEinheitHeader> entities) { + return RepresentationModelAssembler.super.toCollectionModel(entities) + .add(linkTo(OrganisationsEinheitController.class).withSelfRel()); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapper.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..20c30041b9642e66551eaecc771091bec74d7920 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapper.java @@ -0,0 +1,11 @@ +package de.ozgcloud.alfa.collaboration; + +import org.mapstruct.Mapper; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit; + +@Mapper +interface OrganisationsEinheitMapper { + + OrganisationsEinheit fromGrpc(GrpcOrganisationsEinheit organisationsEinheit); +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssembler.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssembler.java new file mode 100644 index 0000000000000000000000000000000000000000..60f582dfdb3477e72aecd9ad3bf3062d37838fdc --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssembler.java @@ -0,0 +1,20 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import de.ozgcloud.alfa.common.ModelBuilder; + +@Component +class OrganisationsEinheitModelAssembler implements RepresentationModelAssembler<OrganisationsEinheit, EntityModel<OrganisationsEinheit>> { + + @Override + public EntityModel<OrganisationsEinheit> toModel(OrganisationsEinheit entity) { + return ModelBuilder.fromEntity(entity) + .addLink(linkTo(OrganisationsEinheitController.class).slash(entity.getId()).withSelfRel()) + .buildModel(); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteService.java new file mode 100644 index 0000000000000000000000000000000000000000..aeea7b92941b95fab767bb1641920739e78b8092 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteService.java @@ -0,0 +1,48 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import de.ozgcloud.alfa.common.GrpcUtil; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetRequest; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetResponse; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitSearchRequest; +import de.ozgcloud.zufi.grpc.organisationseinheit.OrganisationsEinheitServiceGrpc.OrganisationsEinheitServiceBlockingStub; +import net.devh.boot.grpc.client.inject.GrpcClient; + +@Service +class OrganisationsEinheitRemoteService { + + @GrpcClient(GrpcUtil.ZUFI_MANAGER_GRPC_CLIENT) + private OrganisationsEinheitServiceBlockingStub serviceStub; + + @Autowired + private OrganisationsEinheitHeaderMapper organisationsEinheitHeaderMapper; + @Autowired + private OrganisationsEinheitMapper organisationsEinheitMapper; + + public Stream<OrganisationsEinheitHeader> search(String searchBy) { + var response = serviceStub.search(buildSearchRequest(searchBy)); + return response.getOrganisationsEinheitenList().stream().map(organisationsEinheitHeaderMapper::fromGrpc); + } + + private GrpcOrganisationsEinheitSearchRequest buildSearchRequest(String searchBy) { + return GrpcOrganisationsEinheitSearchRequest.newBuilder().setSearchBy(searchBy).build(); + } + + public OrganisationsEinheit getById(String id) { + var request = buildGetByIdRequest(id); + var response = serviceStub.getById(request); + return getOrganisationsEinheitFromGetByIdResponse(response); + } + + GrpcOrganisationsEinheitGetRequest buildGetByIdRequest(String organisationsEinheitId) { + return GrpcOrganisationsEinheitGetRequest.newBuilder().setId(organisationsEinheitId).build(); + } + + OrganisationsEinheit getOrganisationsEinheitFromGetByIdResponse(GrpcOrganisationsEinheitGetResponse response) { + return organisationsEinheitMapper.fromGrpc(response.getOrganisationsEinheit()); + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitService.java new file mode 100644 index 0000000000000000000000000000000000000000..58afb0f569cf5353de916024011e9670f3a9c537 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitService.java @@ -0,0 +1,23 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +class OrganisationsEinheitService { + + private final OrganisationsEinheitRemoteService remoteService; + + public OrganisationsEinheit getById(String id) { + return remoteService.getById(id); + } + + public Stream<OrganisationsEinheitHeader> searchOrganisationsEinheiten(String searchBy) { + return remoteService.search(searchBy); + } + +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/XzufiId.java b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/XzufiId.java new file mode 100644 index 0000000000000000000000000000000000000000..49e1f5f3ecafe67ad7ca22daf104406e5a8f39a2 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/collaboration/XzufiId.java @@ -0,0 +1,11 @@ +package de.ozgcloud.alfa.collaboration; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class XzufiId { + private String id; + private String schemeAgencyId; +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/FeatureToggleProperties.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/FeatureToggleProperties.java index 80238fccb22376ea6e2ff8201f1d7b497a968dae..5b60fc67206213d2be23bda2aebddfb539873da5 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/FeatureToggleProperties.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/FeatureToggleProperties.java @@ -16,4 +16,9 @@ public class FeatureToggleProperties { * Enable mail reply option regardless of other configuration. */ private boolean replyAlwaysAllowed = false; + + /** + * Enable collaboration-feature in Vorgang + */ + private boolean collaborationEnabled = false; } diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/GrpcUtil.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/GrpcUtil.java index d11d5297b8373ad3d84900333a7f996c7e2db650..fc033f658d18ce1a5726b631e32ab7b591c5817f 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/GrpcUtil.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/GrpcUtil.java @@ -38,6 +38,8 @@ public class GrpcUtil { public static final String VORGANG_MANAGER_GRPC_CLIENT = "vorgang-manager"; + public static final String ZUFI_MANAGER_GRPC_CLIENT = "zufi-manager"; + public static final String SERVICE_KEY = "GRPC_SERVICE"; public static Key<String> keyOfString(String key) { diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/Command.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/Command.java index 1a5d2ac5fda7dc714f9f2b13514dc22705159219..9e862c4756eb06bfa8aa47591b7d390de442d865 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/Command.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/Command.java @@ -26,8 +26,6 @@ package de.ozgcloud.alfa.common.command; import java.time.ZonedDateTime; import java.util.Map; -import org.apache.commons.lang3.StringUtils; - import com.fasterxml.jackson.annotation.JsonIgnore; import de.ozgcloud.alfa.common.LinkedUserProfileResource; @@ -76,14 +74,4 @@ public class Command { public CommandOrder getCommandOrder() { return CommandOrder.fromOrder(order); } - - @JsonIgnore - public boolean isFinishedSuccessfully() { - return status == CommandStatus.FINISHED && StringUtils.isEmpty(errorMessage); - } - - @JsonIgnore - public boolean isNotDone() { - return status.isNotDone(); - } } \ No newline at end of file diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandBody.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandBody.java index 8bdf5bbab7eee1d9c6040bdc844cc568546e88bd..42f3ec714dd2a8c466b34cf2670977e2eba6d45a 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandBody.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandBody.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import de.ozgcloud.alfa.aktenzeichen.AktenzeichenCommandBody; import de.ozgcloud.alfa.bescheid.Bescheid; import de.ozgcloud.alfa.bescheid.BescheidDocumentFromFileBody; +import de.ozgcloud.alfa.collaboration.Collaboration; import de.ozgcloud.alfa.kommentar.Kommentar; import de.ozgcloud.alfa.loeschanforderung.DeleteLoeschAnforderung; import de.ozgcloud.alfa.loeschanforderung.LoeschAnforderung; @@ -54,7 +55,8 @@ import de.ozgcloud.alfa.wiedervorlage.Wiedervorlage; @Type(value = Bescheid.class, name = "UPDATE_BESCHEID"), @Type(value = ProcessVorgangBody.class, name = "PROCESS_VORGANG"), @Type(value = AktenzeichenCommandBody.class, name = "SET_AKTENZEICHEN"), - @Type(value = BescheidDocumentFromFileBody.class, name = "CREATE_BESCHEID_DOCUMENT_FROM_FILE") + @Type(value = BescheidDocumentFromFileBody.class, name = "CREATE_BESCHEID_DOCUMENT_FROM_FILE"), + @Type(value = Collaboration.class, name = "CREATE_COLLABORATION_REQUEST") }) public interface CommandBody { } diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandModelAssembler.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandModelAssembler.java index 843c170180851a807ec32a4cdc2cc2c019095d3e..d8825572907482b5b7acaae723eea79c2cde445b 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandModelAssembler.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandModelAssembler.java @@ -38,6 +38,7 @@ import org.springframework.stereotype.Component; import de.ozgcloud.alfa.bescheid.BescheidController; import de.ozgcloud.alfa.bescheid.DocumentController; +import de.ozgcloud.alfa.collaboration.CollaborationController; import de.ozgcloud.alfa.common.ModelBuilder; import de.ozgcloud.alfa.kommentar.KommentarController; import de.ozgcloud.alfa.postfach.PostfachMailController; @@ -82,6 +83,7 @@ class CommandModelAssembler implements RepresentationModelAssembler<Command, Ent case WIEDERVORLAGE -> linkTo(WiedervorlageController.class).slash(entity.getRelationId()); case BESCHEID -> linkTo(methodOn(BescheidController.class).getDraft(entity.getVorgangId())); case DOCUMENT -> linkTo(DocumentController.class).slash(entity.getCreatedResource()); + case COLLABORATION -> linkTo(methodOn(CollaborationController.class).getAllByVorgangId(entity.getVorgangId())); case NONE -> throw new IllegalArgumentException("Unknown CommandOrder: " + entity.getOrder()); }; diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandOrder.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandOrder.java index 2ecc444afffdd33e851266564ec0356fd19ff164..f2a74744787b4e860b4b0ce2234a62719625dffc 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandOrder.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandOrder.java @@ -73,10 +73,12 @@ public enum CommandOrder { PROCESS_VORGANG(false, Type.VORGANG), + CREATE_COLLABORATION_REQUEST(false, Type.COLLABORATION), + UNBEKANNT(false, Type.NONE); enum Type { - VORGANG, VORGANG_LIST, WIEDERVORLAGE, KOMMENTAR, FORWARDING, POSTFACH, BESCHEID, DOCUMENT, NONE + VORGANG, VORGANG_LIST, WIEDERVORLAGE, KOMMENTAR, FORWARDING, POSTFACH, BESCHEID, DOCUMENT, COLLABORATION, NONE } private final boolean revokeable; diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandService.java index da98c4e572fda1d5c7175040bd88eace508555f6..dc573993c67ac660e97502ae4087e66d43ed3928 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandService.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandService.java @@ -23,7 +23,6 @@ */ package de.ozgcloud.alfa.common.command; -import java.util.Calendar; import java.util.Optional; import java.util.stream.Stream; @@ -43,8 +42,6 @@ import lombok.RequiredArgsConstructor; public class CommandService { static final long NO_RELATION_VERSION = -1; - private static final int WAIT_TIME_MS = 500; - private static final int COMMAND_REQUEST_THRESHOLD_MILLIS = 10000; private final CommandRemoteService remoteService; @@ -114,25 +111,4 @@ public class CommandService { return remoteService.findCommands(vorgangId, Optional.of(CommandStatus.FINISHED), Optional.empty()); } - public Command waitUntilDone(Command commandToWaitFor) { - var command = commandToWaitFor; - var calendar = Calendar.getInstance(); - var timeout = calendar.getTimeInMillis() + COMMAND_REQUEST_THRESHOLD_MILLIS; - while (command.isNotDone() && calendar.getTimeInMillis() < timeout) { - synchronized (this) { - try { - wait(WAIT_TIME_MS); - command = reloadCommand(command.getId()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - return command; - } - - public Command reloadCommand(String commandId) { - return remoteService.getCommand(commandId); - } - } \ No newline at end of file diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandStatus.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandStatus.java index 548107b825bca6aaed64a50f6f24611c9d7a8174..285ebdaae3817652aa5c80b1d9817628fd044e6c 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandStatus.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/command/CommandStatus.java @@ -23,14 +23,6 @@ */ package de.ozgcloud.alfa.common.command; -import java.util.Set; - public enum CommandStatus { PENDING, FINISHED, ERROR, REVOKE_PENDING, REVOKED; - - private static final Set<CommandStatus> FINAL_STATES = Set.of(FINISHED, ERROR, REVOKED); - - public boolean isNotDone() { - return !FINAL_STATES.contains(this); - } } diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/CurrentUserService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/CurrentUserService.java index f4a517c4052a3658f8ecfc3d81e5ac5a6593c849..a62746f45d4920e8e787e2beca665ecf137d8a99 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/CurrentUserService.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/CurrentUserService.java @@ -29,7 +29,6 @@ import java.util.List; import java.util.Objects; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; @@ -37,7 +36,9 @@ import org.springframework.stereotype.Service; import de.ozgcloud.alfa.common.binaryfile.AlfaUserWithFileId; import de.ozgcloud.common.errorhandling.TechnicalException; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor @Service public class CurrentUserService { @@ -51,10 +52,9 @@ public class CurrentUserService { static final String KEYCLOAK_USER_GIVEN_NAME = "given_name"; static final String KEYCLOAK_USER_FAMILY_NAME = "family_name"; - @Autowired - private UserService userService; - @Autowired - private RoleHierarchy roleHierarchy; + private final UserService userService; + + private final RoleHierarchy roleHierarchy; public boolean hasRole(String role) { return CurrentUserHelper.hasRole(role) || hasRoleReachable(role); diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/postfach/PostfachMailService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/postfach/PostfachMailService.java index 732530317ed1c1140ac3e702bb0c51b38d528cea..17c5a69936b94b265c1eaf802fcc4f49802aca91 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/postfach/PostfachMailService.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/postfach/PostfachMailService.java @@ -161,7 +161,7 @@ class PostfachMailService { } return postfachConfigGroup.getPostfachConfigs().stream() - .filter(postfachConfig -> postfachConfig.getType().equals(serviceKontoType)) + .filter(postfachConfig -> postfachConfig.getType().equalsIgnoreCase(serviceKontoType)) .map(PostfachConfig::isReplyAllowed) .findFirst() .orElse(false); diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandController.java b/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandController.java index 2cc413979da3622e47fc9d0d5677b804f001cf23..7f068905ec5b2f0c464fe677dc4cfa10634847c2 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandController.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandController.java @@ -53,23 +53,26 @@ public class WiedervorlageCommandController { @PathVariable long wiedervorlageVersion) { var wiedervorlage = service.getById(wiedervorlageId); var createdCommand = createCommand(wiedervorlage, command); - var doneCommand = service.updateNextFrist(createdCommand, wiedervorlage.getVorgangId()); - return ResponseEntity.created(linkTo(CommandController.class).slash(doneCommand.getId()).toUri()).build(); + return ResponseEntity.created(linkTo(CommandController.class).slash(createdCommand.getId()).toUri()).build(); } Command createCommand(Wiedervorlage wiedervorlage, CreateCommand command) { switch (command.getOrder()) { case LegacyOrder.WIEDERVORLAGE_ERLEDIGEN: { - return service.erledigen(wiedervorlage); + var changed = wiedervorlage.toBuilder().done(true).build(); + service.updateNextFrist(wiedervorlage.getVorgangId(), changed); + return service.erledigen(changed); } case LegacyOrder.WIEDERVORLAGE_WIEDEREROEFFNEN: { - return service.wiedereroeffnen(wiedervorlage); + var changed = wiedervorlage.toBuilder().done(false).build(); + service.updateNextFrist(wiedervorlage.getVorgangId(), changed); + return service.wiedereroeffnen(changed); } case LegacyOrder.EDIT_WIEDERVORLAGE: { - return service.editWiedervorlage(updateWiedervorlageByCommand(wiedervorlage, (Wiedervorlage) command.getBody()), - wiedervorlage.getId(), - wiedervorlage.getVersion()); + var changed = updateWiedervorlageByCommand(wiedervorlage, (Wiedervorlage) command.getBody()); + service.updateNextFrist(wiedervorlage.getVorgangId(), changed); + return service.editWiedervorlage(changed, wiedervorlage.getId(), wiedervorlage.getVersion()); } default: throw new TechnicalException("Unsupported order " + command.getOrder()); @@ -95,10 +98,12 @@ public class WiedervorlageCommandController { @PostMapping public ResponseEntity<Void> createWiedervorlage(@RequestBody CreateCommand command, @PathVariable String vorgangId) { - var createdCommand = service.createWiedervorlage((Wiedervorlage) command.getBody(), vorgangId); - var doneCommand = service.updateNextFrist(createdCommand, createdCommand.getVorgangId()); + var wiedervorlage = (Wiedervorlage) command.getBody(); + var createdCommand = service.createWiedervorlage(wiedervorlage, vorgangId); - return ResponseEntity.created(linkTo(CommandController.class).slash(doneCommand.getId()).toUri()).build(); + service.updateNextFrist(vorgangId, wiedervorlage); + + return ResponseEntity.created(linkTo(CommandController.class).slash(createdCommand.getId()).toUri()).build(); } } } \ No newline at end of file diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageService.java b/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageService.java index 28f4795c3695c12aa76981ebcb01c2f807fbbbde..c61984151d2b6dd72de917a16391c28aedad325c 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageService.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageService.java @@ -33,12 +33,12 @@ import java.util.stream.Stream; import jakarta.validation.Valid; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import de.ozgcloud.alfa.common.attacheditem.VorgangAttachedItemService; import de.ozgcloud.alfa.common.command.Command; -import de.ozgcloud.alfa.common.command.CommandService; import de.ozgcloud.alfa.common.user.CurrentUserService; import lombok.RequiredArgsConstructor; @@ -52,7 +52,6 @@ class WiedervorlageService { private final WiedervorlageRemoteService remoteService; private final VorgangAttachedItemService vorgangAttachedItemService; private final CurrentUserService currentUserService; - private final CommandService commandService; public Command createWiedervorlage(@Valid Wiedervorlage wiedervorlage, String vorgangId) { return vorgangAttachedItemService.createNewWiedervorlage(addCreated(wiedervorlage), vorgangId); @@ -73,18 +72,14 @@ class WiedervorlageService { return remoteService.getById(wiedervorlageId); } - public Command updateNextFrist(Command command, String vorgangId) { - var doneCommand = commandService.waitUntilDone(command); - if (doneCommand.isFinishedSuccessfully()) { - doUpdateNextFrist(vorgangId); - } - return doneCommand; - } - - void doUpdateNextFrist(String vorgangId) { - var allWiedervorlagen = findByVorgangId(vorgangId); + @Async + public void updateNextFrist(String vorgangId, Wiedervorlage changedOrNewWiedervorlage) { + var persistedWiedervorlagen = findByVorgangId(vorgangId); + var persistedWiedervorlagenExcludingChanged = persistedWiedervorlagen.filter( + wiedervorlage -> !wiedervorlage.getId().equals(changedOrNewWiedervorlage.getId())); - remoteService.updateNextFrist(vorgangId, calculateNextFrist(allWiedervorlagen)); + var wiedervorlagen = Stream.concat(persistedWiedervorlagenExcludingChanged, Stream.of(changedOrNewWiedervorlage)); + remoteService.updateNextFrist(vorgangId, calculateNextFrist(wiedervorlagen)); } Optional<LocalDate> calculateNextFrist(Stream<Wiedervorlage> wiedervorlagen) { @@ -100,11 +95,11 @@ class WiedervorlageService { return remoteService.findByVorgangId(vorgangId); } - Command erledigen(Wiedervorlage wiedervorlage) { + public Command erledigen(Wiedervorlage wiedervorlage) { return vorgangAttachedItemService.setWiedervorlageDone(wiedervorlage, true); } - Command wiedereroeffnen(Wiedervorlage wiedervorlage) { + public Command wiedereroeffnen(Wiedervorlage wiedervorlage) { return vorgangAttachedItemService.setWiedervorlageDone(wiedervorlage, false); } diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java index 159f038b04b56863c3532950ec5ebef062bf48f4..540170e893ab437f58b3609e4b1ab16c3971e4cd 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidModelAssemblerTest.java @@ -249,7 +249,7 @@ class BescheidModelAssemblerTest { callMethod(); - verify(assembler).toCollectionModel(List.of(bescheid)); + verify(assembler).toModel(bescheid); } @Test diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidVorgangProcessorTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidVorgangProcessorTest.java index fc714d41d1e45acb791372084e94c4c0b0bddab8..1b897c7d005dadfed9e1a76145eb873061b31e1b 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidVorgangProcessorTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/bescheid/BescheidVorgangProcessorTest.java @@ -20,6 +20,7 @@ import org.mockito.Spy; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; +import de.ozgcloud.alfa.common.EntityModelTestFactory; import de.ozgcloud.alfa.common.UserProfileUrlProvider; import de.ozgcloud.alfa.common.user.CurrentUserService; import de.ozgcloud.alfa.common.user.UserRole; @@ -27,7 +28,6 @@ import de.ozgcloud.alfa.vorgang.Vorgang; import de.ozgcloud.alfa.vorgang.VorgangHeaderTestFactory; import de.ozgcloud.alfa.vorgang.VorgangWithEingang; import de.ozgcloud.alfa.vorgang.VorgangWithEingangTestFactory; -import lombok.NoArgsConstructor; class BescheidVorgangProcessorTest { @@ -47,11 +47,9 @@ class BescheidVorgangProcessorTest { @Test void shouldReturnTheSameModelOnNullVorgang() { - var inputModel = new NullableEntityModel(); + var processedModel = processor.process(EntityModelTestFactory.NULLABLE); - var processedModel = processor.process(inputModel); - - assertThat(processedModel).isEqualTo(inputModel); + assertThat(processedModel).isEqualTo(EntityModelTestFactory.NULLABLE); } @Test @@ -175,11 +173,6 @@ class BescheidVorgangProcessorTest { private EntityModel<VorgangWithEingang> callProcess() { return processor.process(EntityModel.of(vorgang)); } - - @NoArgsConstructor - private static class NullableEntityModel extends EntityModel<VorgangWithEingang> { - - } } @DisplayName("Exists bescheid") diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/AnschriftTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/AnschriftTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..6036b9c1544846dd762deffc756f5ef6ac94b456 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/AnschriftTestFactory.java @@ -0,0 +1,24 @@ +package de.ozgcloud.alfa.collaboration; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.alfa.collaboration.Anschrift.AnschriftBuilder; + +public class AnschriftTestFactory { + public static final String PLZ = LoremIpsum.getInstance().getZipCode(); + public static final String ORT = LoremIpsum.getInstance().getCity(); + public static final String STRASSE = LoremIpsum.getInstance().getWords(2); + public static final String HAUSNUMMER = String.valueOf((int) (Math.random() * 1000)); + + public static Anschrift create() { + return createBuilder().build(); + } + + public static AnschriftBuilder createBuilder() { + return Anschrift.builder() + .strasse(STRASSE) + .ort(ORT) + .hausnummer(HAUSNUMMER) + .plz(PLZ); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..87122cbb75c58ec12784d345654fd573f534ed70 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationControllerTest.java @@ -0,0 +1,148 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Collections; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.util.UriComponentsBuilder; + +import lombok.SneakyThrows; + +class CollaborationControllerTest { + + @InjectMocks + private CollaborationController controller; + + @Mock + private CollaborationService service; + + @Mock + private CollaborationModelAssembler assembler; + + private MockMvc mockMvc; + + @BeforeEach + void initTest() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Nested + class TestGetAllByVorgangId { + + private final Collaboration collaboration = CollaborationTestFactory.create(); + + @SneakyThrows + @Test + void shouldCallCollaborationService() { + performRequest(); + + verify(service).getCollaborations(CollaborationTestFactory.VORGANG_ID); + } + + @Nested + class TestOnExistingCollaboration { + + private final Stream<Collaboration> collaborations = Stream.of(collaboration); + + @BeforeEach + void mock() { + when(service.getCollaborations(CollaborationTestFactory.VORGANG_ID)).thenReturn(collaborations); + } + + @Test + void shouldCallAssembler() { + performRequest(); + + verify(assembler).toCollectionModel(collaborations, CollaborationTestFactory.VORGANG_ID); + } + + @SneakyThrows + @Test + void shouldReturnStatusOk() { + when(assembler.toCollectionModel(collaborations, CollaborationTestFactory.VORGANG_ID)) + .thenReturn(CollectionModel.of(Collections.singletonList(EntityModel.of(collaboration)))); + + var response = performRequest(); + + response.andExpect(status().isOk()); + } + + @Nested + class TestResponseBody { + + @BeforeEach + void mockAssembler() { + when(assembler.toCollectionModel(collaborations, CollaborationTestFactory.VORGANG_ID)) + .thenReturn(CollectionModel.of(Collections.singletonList(EntityModel.of(collaboration)))); + } + + @SneakyThrows + @Test + void shouldNotHaveVorgangId() { + var response = performRequest(); + + response.andExpect(jsonPath("$.vorgangId").doesNotExist()); + } + + @SneakyThrows + @Test + void shouldNotHaveCollaborationVorgangId() { + var response = performRequest(); + + response.andExpect(jsonPath("$.collaborationVorgangId").doesNotExist()); + } + + @SneakyThrows + @Test + void shouldHaveTitel() { + var response = performRequest(); + + response.andExpect(jsonPath("$.content[0].titel").value(CollaborationTestFactory.TITEL)); + } + + @SneakyThrows + @Test + void shouldHaveAnfrage() { + var response = performRequest(); + + System.out.println(response.andReturn().getResponse().getContentAsString()); + + response.andExpect(jsonPath("$.content[0].anfrage").value(CollaborationTestFactory.ANFRAGE)); + } + + @SneakyThrows + @Test + void shouldHaveZustaendigeStelle() { + var expectedLink = UriComponentsBuilder + .fromUriString("http://localhost") + .path(OrganisationsEinheitController.PATH) + .pathSegment(CollaborationTestFactory.ZUSTAENDIGE_STELLE) + .build(); + + var response = performRequest(); + + response.andExpect(jsonPath("$.content[0].zustaendigeStelle") + .value(expectedLink.toString())); + } + } + } + + @SneakyThrows + private ResultActions performRequest() { + return mockMvc.perform(get(CollaborationController.PATH + "/" + CollaborationTestFactory.VORGANG_ID + "/collaborations")); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssemblerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..42ccdb43a2d39ed2bf5f6f71874eec71e57d36ed --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationModelAssemblerTest.java @@ -0,0 +1,183 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.UriTemplate; + +import de.ozgcloud.alfa.common.ModelBuilder; +import de.ozgcloud.alfa.common.command.CommandController.CommandByRelationController; +import de.ozgcloud.alfa.vorgang.VorgangController; +import de.ozgcloud.alfa.vorgang.VorgangHeaderTestFactory; +import de.ozgcloud.alfa.vorgang.VorgangWithEingang; +import de.ozgcloud.alfa.vorgang.VorgangWithEingangTestFactory; + +class CollaborationModelAssemblerTest { + + @Spy + @InjectMocks + private CollaborationModelAssembler assembler; + + @Mock + private VorgangController vorgangController; + + @Nested + class TestToModel { + + private static final String REL_ZUSTAENDIGE_STELLE = "zustaendigeStelle"; + private final Collaboration collaboration = CollaborationTestFactory.create(); + + @Test + void shouldHaveContent() { + var entityModel = callAssembler(); + + assertThat(entityModel.getContent()).isEqualTo(collaboration); + } + + @Test + void shouldHaveLinkToOrganisationsEinheit() { + var entityModel = callAssembler(); + + assertThat(entityModel.getLink(REL_ZUSTAENDIGE_STELLE)).get().extracting(Link::getHref) + .isEqualTo(OrganisationsEinheitController.PATH + "/" + OrganisationsEinheitTestFactory.ID); + } + + private EntityModel<Collaboration> callAssembler() { + return assembler.toModel(collaboration); + } + } + + @Nested + class TestToCollectionModel { + private final Collaboration collaboration = CollaborationTestFactory.create(); + + @Nested + class OnNonEmptyCollaborationList { + + @Test + void shouldCallToModel() { + callAssembler(); + + verify(assembler).toModel(collaboration); + } + + @Test + void shouldHaveEntityModel() { + var entityModel = ModelBuilder.fromEntity(collaboration).buildModel(); + doReturn(entityModel).when(assembler).toModel(collaboration); + + var collectionModel = callAssembler(); + + assertThat(collectionModel.getContent()).containsExactly(entityModel); + } + + @Test + void shouldHaveSelfLink() { + var collectionModel = callAssembler(); + + assertThat(collectionModel.getLink(IanaLinkRelations.SELF_VALUE)).get().extracting(Link::getHref) + .isEqualTo(CollaborationController.PATH + "/" + VorgangHeaderTestFactory.ID + "/collaborations"); + } + + @Test + void shouldNotHaveCreateCollaborationRequestLink() { + var collectionModel = callAssembler(); + + assertThat(collectionModel.getLink(CollaborationModelAssembler.REL_CREATE_COLLABORATION_REQUEST)).isEmpty(); + } + + private CollectionModel<EntityModel<Collaboration>> callAssembler() { + return assembler.toCollectionModel(Stream.of(collaboration), VorgangHeaderTestFactory.ID); + } + } + + @Nested + class OnEmptyCollaborationList { + + @Mock + private Link createCollaborationRequestLink; + + @BeforeEach + void mock() { + doReturn(createCollaborationRequestLink).when(assembler).buildCreateCollaborationRequestLink(VorgangHeaderTestFactory.ID); + } + + @Test + void shouldHaveEmptyContent() { + var collectionModel = callAssembler(); + + assertThat(collectionModel.getContent()).isEmpty(); + } + + @Test + void shouldHaveSelfLink() { + var collectionModel = callAssembler(); + + assertThat(collectionModel.getLink(IanaLinkRelations.SELF_VALUE)).get().extracting(Link::getHref) + .isEqualTo(CollaborationController.PATH + "/" + VorgangHeaderTestFactory.ID + "/collaborations"); + } + + @Test + void shouldHaveCreateCollaborationRequestLink() { + var collectionModel = callAssembler(); + + assertThat(collectionModel.getLinks()).contains(createCollaborationRequestLink); + } + + private CollectionModel<EntityModel<Collaboration>> callAssembler() { + return assembler.toCollectionModel(Stream.empty(), VorgangHeaderTestFactory.ID); + } + } + } + + @Nested + class TestBuildCreateCollaborationRequestLink { + + private final VorgangWithEingang vorgang = VorgangWithEingangTestFactory.create(); + + @BeforeEach + void mockVorgangController() { + when(vorgangController.getVorgang(VorgangHeaderTestFactory.ID)).thenReturn(vorgang); + } + + @Test + void shouldCallVorgangController() { + callAssembler(); + + verify(vorgangController).getVorgang(VorgangHeaderTestFactory.ID); + } + + @Test + void shouldHaveHrefToCreateCommand() { + var expectedHref = UriTemplate.of(CommandByRelationController.COMMAND_BY_RELATION_PATH) + .expand(VorgangHeaderTestFactory.ID, VorgangHeaderTestFactory.ID, VorgangHeaderTestFactory.VERSION).toString(); + + var link = callAssembler(); + + assertThat(link).extracting(Link::getHref).isEqualTo(expectedHref); + } + + @Test + void shouldHaveCreateCollaborationRequestRelation() { + var link = callAssembler(); + + assertThat(link).extracting(Link::getRel).isEqualTo(CollaborationModelAssembler.REL_CREATE_COLLABORATION_REQUEST); + } + + private Link callAssembler() { + return assembler.buildCreateCollaborationRequestLink(VorgangHeaderTestFactory.ID); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..2659c7fce3f402080694a5b38cdd0fe11f3507f1 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationServiceTest.java @@ -0,0 +1,84 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +class CollaborationServiceTest { + + @InjectMocks + private CollaborationService service; + + @Mock + private CollaborationRemoteService remoteService; + + @Nested + class TestGetCollaborations { + + private final String id = UUID.randomUUID().toString(); + + @Test + void shouldCallRemoteService() { + callService(); + + verify(remoteService).getCollaborations(id); + } + + @Test + void shouldReturnCollaboration() { + var collaboration = CollaborationTestFactory.create(); + when(remoteService.getCollaborations(id)).thenReturn(Stream.of(collaboration)); + + var returnedCollaboration = callService(); + + assertThat(returnedCollaboration).contains(collaboration); + } + + private Stream<Collaboration> callService() { + return service.getCollaborations(id); + } + } + + @Nested + class TestHasCollaboration { + + private final String id = UUID.randomUUID().toString(); + + @Test + void shouldCallRemoteService() { + callService(); + + verify(remoteService).getCollaborations(id); + } + + @Test + void shouldReturnTrue() { + var collaboration = CollaborationTestFactory.create(); + when(remoteService.getCollaborations(id)).thenReturn(Stream.of(collaboration)); + + var hasCollaboration = callService(); + + assertThat(hasCollaboration).isTrue(); + } + + @Test + void shouldReturnFalse() { + when(remoteService.getCollaborations(id)).thenReturn(Stream.empty()); + + var hasCollaboration = callService(); + + assertThat(hasCollaboration).isFalse(); + } + + private boolean callService() { + return service.hasCollaboration(id); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..133acf57bb11cd6d048f2c578405f4ceadd4ae4b --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationTestFactory.java @@ -0,0 +1,30 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.UUID; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.alfa.collaboration.Collaboration.CollaborationBuilder; + +public class CollaborationTestFactory { + + public static final String VORGANG_ID = UUID.randomUUID().toString(); + public static final String COLLABORATION_VORGANG_ID = UUID.randomUUID().toString(); + public static final String TITEL = LoremIpsum.getInstance().getWords(7); + public static final String ANFRAGE = LoremIpsum.getInstance().getParagraphs(2, 5); + public static final String ZUSTAENDIGE_STELLE = OrganisationsEinheitTestFactory.ID; + + public static Collaboration create() { + return createBuilder() + .build(); + } + + private static CollaborationBuilder createBuilder() { + return Collaboration.builder() + .vorgangId(VORGANG_ID) + .collaborationVorgangId(COLLABORATION_VORGANG_ID) + .titel(TITEL) + .anfrage(ANFRAGE) + .zustaendigeStelle(ZUSTAENDIGE_STELLE); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorITCase.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorITCase.java new file mode 100644 index 0000000000000000000000000000000000000000..fe189548817a2ec6f40b4eb9536d0e2f8cef3cdb --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorITCase.java @@ -0,0 +1,32 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +class CollaborationVorgangProcessorITCase { + + @SpringBootTest(properties = {"ozgcloud.feature.collaboration-enabled=true"}) + @Nested + class OnFeatureEnabled { + + @Test + void shouldExistInApplicationContext(ApplicationContext context) { + assertThat(context.getBean(CollaborationVorgangProcessor.class)).isNotNull(); + } + } + + @SpringBootTest + @Nested + class OnFeatureDisabled { + + @Test + void shouldNotExistInApplicationContext(ApplicationContext context) { + assertThatThrownBy(() -> context.getBean(CollaborationVorgangProcessor.class)).isInstanceOf(NoSuchBeanDefinitionException.class); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..8e28fca8d28c0752644af390b656228fef93ea26 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/CollaborationVorgangProcessorTest.java @@ -0,0 +1,163 @@ +package de.ozgcloud.alfa.collaboration; + +import static de.ozgcloud.alfa.common.UserProfileUrlProviderTestFactory.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.web.util.UriComponentsBuilder; + +import de.ozgcloud.alfa.common.EntityModelTestFactory; +import de.ozgcloud.alfa.common.UserProfileUrlProvider; +import de.ozgcloud.alfa.common.user.CurrentUserService; +import de.ozgcloud.alfa.common.user.UserRole; +import de.ozgcloud.alfa.vorgang.VorgangHeaderTestFactory; +import de.ozgcloud.alfa.vorgang.VorgangWithEingang; +import de.ozgcloud.alfa.vorgang.VorgangWithEingangTestFactory; + +class CollaborationVorgangProcessorTest { + + @Spy + @InjectMocks + private CollaborationVorgangProcessor processor; + + @Mock + private CollaborationService collaborationService; + + @Mock + private CurrentUserService currentUserService; + + private final UserProfileUrlProvider urlProvider = new UserProfileUrlProvider(); + + @Nested + class TestProcess { + + @Nested + class OnNullVorgang { + + @Test + void shouldNotAddLinksIfVorgangIsNull() { + var model = processor.process(EntityModelTestFactory.NULLABLE); + + assertThat(model.hasLinks()).isFalse(); + } + } + + @Nested + class OnNotVerwaltungUserRole { + + @BeforeEach + void mockUserService() { + when(currentUserService.hasRole(UserRole.VERWALTUNG_USER)).thenReturn(false); + } + + @Test + void shouldNotAddLinks() { + var model = processor.process(EntityModel.of(VorgangWithEingangTestFactory.create())); + + assertThat(model.hasLinks()).isFalse(); + } + + } + + @Nested + class OnNonNullVorgangAndOnVerwaltungUserRole { + + @BeforeEach + void prepareBuilder() { + initUserProfileUrlProvider(urlProvider); + } + + @BeforeEach + void mockUserService() { + when(currentUserService.hasRole(UserRole.VERWALTUNG_USER)).thenReturn(true); + } + + @Nested + class OnCollaborationPresent { + + @BeforeEach + void setUpMock() { + when(collaborationService.hasCollaboration(VorgangHeaderTestFactory.ID)).thenReturn(true); + } + + @Test + void shouldHaveTwoLinks() { + var model = callProcessor(); + + assertThat(model.getLinks()).hasSize(2); + } + + @Test + void shouldNotAddSearchOrganisationsEinheitLink() { + var model = callProcessor(); + + assertThat(model.getLink(CollaborationVorgangProcessor.REL_SEARCH_ORGANISATIONS_EINHEIT)).isEmpty(); + } + + @Test + void shouldAddCollaborationsLink() { + var model = callProcessor(); + + assertThat(model.getLink(CollaborationVorgangProcessor.REL_COLLABORATIONS)).get() + .extracting(Link::getHref) + .isEqualTo(CollaborationController.PATH + "/" + VorgangHeaderTestFactory.ID + "/collaborations"); + } + } + + @Nested + class OnCollaborationsNotPresent { + + @BeforeEach + void setUpMock() { + when(collaborationService.hasCollaboration(VorgangHeaderTestFactory.ID)).thenReturn(false); + } + + @Test + void shouldHaveThreeLinks() { + var model = callProcessor(); + + assertThat(model.getLinks()).hasSize(3); + + } + + @Test + void shouldAddSearchOrganisationsEinheitLink() { + var expectedHref = UriComponentsBuilder.fromUriString(OrganisationsEinheitController.PATH) + .queryParam(OrganisationsEinheitController.SEARCH_BY_PARAM, "{" + OrganisationsEinheitController.SEARCH_BY_PARAM + "}") + .build().toString(); + + var model = callProcessor(); + + assertThat(model.getLink(CollaborationVorgangProcessor.REL_SEARCH_ORGANISATIONS_EINHEIT)).get() + .extracting(Link::getHref) + .isEqualTo(expectedHref); + } + + @Test + void shouldAddCollaborationsLink() { + var expectedHref = UriComponentsBuilder.fromUriString(CollaborationController.PATH) + .pathSegment(VorgangHeaderTestFactory.ID, "collaborations") + .build().toString(); + + var model = callProcessor(); + + assertThat(model.getLink(CollaborationVorgangProcessor.REL_COLLABORATIONS)).get() + .extracting(Link::getHref) + .isEqualTo(expectedHref); + } + } + + private EntityModel<VorgangWithEingang> callProcessor() { + return processor.process(EntityModel.of(VorgangWithEingangTestFactory.create())); + } + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcAnschriftTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcAnschriftTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..8f06c7fdd95ab06bae9054f94a72a1cd45f6a47b --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcAnschriftTestFactory.java @@ -0,0 +1,24 @@ +package de.ozgcloud.alfa.collaboration; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcAnschrift; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcAnschrift.Builder; + +public class GrpcAnschriftTestFactory { + public static final String STRASSE = AnschriftTestFactory.STRASSE; + public static final String HAUSNUMMER = AnschriftTestFactory.HAUSNUMMER; + public static final String PLZ = AnschriftTestFactory.PLZ; + public static final String ORT = AnschriftTestFactory.ORT; + + public static GrpcAnschrift create() { + return createBuilder().build(); + } + + public static Builder createBuilder() { + return GrpcAnschrift.newBuilder() + .setStrasse(STRASSE) + .setHausnummer(HAUSNUMMER) + .setPlz(PLZ) + .setOrt(ORT); + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetRequestTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetRequestTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..518acd646c25f50be9cdffffb43e412b8ccc4bea --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetRequestTestFactory.java @@ -0,0 +1,19 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.UUID; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetRequest; + +class GrpcOrganisationsEinheitGetRequestTestFactory { + + public static final String ID = UUID.randomUUID().toString(); + + public static GrpcOrganisationsEinheitGetRequest create() { + return createBuilder().build(); + } + + public static GrpcOrganisationsEinheitGetRequest.Builder createBuilder() { + return GrpcOrganisationsEinheitGetRequest.newBuilder().setId(ID); + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetResponseTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetResponseTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..a1c5a313d04f5d631b84b13579b84cbbdd71fedd --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitGetResponseTestFactory.java @@ -0,0 +1,18 @@ +package de.ozgcloud.alfa.collaboration; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetResponse; + +class GrpcOrganisationsEinheitGetResponseTestFactory { + + public static final GrpcOrganisationsEinheit GRPC_ORGANISATIONS_EINHEIT = GrpcOrganisationsEinheitTestFactory.create(); + + public static GrpcOrganisationsEinheitGetResponse create() { + return createBuilder().build(); + } + + public static GrpcOrganisationsEinheitGetResponse.Builder createBuilder() { + return GrpcOrganisationsEinheitGetResponse.newBuilder().setOrganisationsEinheit(GRPC_ORGANISATIONS_EINHEIT); + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitSearchResponseTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitSearchResponseTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..34d6204cb5a1ea202f1fa9b7565f40d27b12ab12 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitSearchResponseTestFactory.java @@ -0,0 +1,20 @@ +package de.ozgcloud.alfa.collaboration; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitSearchResponse; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitSearchResponse.Builder; + +public class GrpcOrganisationsEinheitSearchResponseTestFactory { + + public static final GrpcOrganisationsEinheit ORGANISATIONS_EINHEIT = GrpcOrganisationsEinheitTestFactory.create(); + + public static GrpcOrganisationsEinheitSearchResponse create() { + return createBuilder() + .build(); + } + + public static Builder createBuilder() { + return GrpcOrganisationsEinheitSearchResponse.newBuilder() + .addOrganisationsEinheiten(ORGANISATIONS_EINHEIT); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..7cb95a9e54f601044af15c30c06e783fdf50a09e --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcOrganisationsEinheitTestFactory.java @@ -0,0 +1,44 @@ +package de.ozgcloud.alfa.collaboration; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcAnschrift; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit.Builder; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcZustaendigkeit; + +public class GrpcOrganisationsEinheitTestFactory { + public static final String ID = OrganisationsEinheitHeaderTestFactory.ID; + public static final GrpcXzufiId XZUFI_ID = GrpcXzufiIdTestFactory.create(); + public static final String NAME = OrganisationsEinheitHeaderTestFactory.NAME; + public static final String SYNONYME = LoremIpsum.getInstance().getWords(5); + public static final String VORGANG_MANAGER_ADDRESS = LoremIpsum.getInstance().getUrl(); + public static final GrpcAnschrift ANSCHRIFT = GrpcAnschriftTestFactory.create(); + public static final GrpcZustaendigkeit ZUSTAENDIGKEIT = GrpcZustaendigkeitTestFactory.create(); + + public static GrpcOrganisationsEinheit create() { + return createBuilder().build(); + } + + public static GrpcOrganisationsEinheit createWithoutSynonyme() { + return createBuilderWithoutSynonyme() + .build(); + } + + public static Builder createBuilder() { + return createBuilderWithoutSynonyme() + .setSynonyme(SYNONYME); + } + + public static Builder createBuilderWithoutSynonyme() { + return GrpcOrganisationsEinheit.newBuilder() + .setId(ID) + .setName(NAME) + .setAnschrift(ANSCHRIFT) + .setVorgangManagerAddress(VORGANG_MANAGER_ADDRESS) + .addZustaendigkeiten(ZUSTAENDIGKEIT) + .setXzufiId(XZUFI_ID); + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcXzufiIdTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcXzufiIdTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..5ed243a1e9fc69b34c7947487c98af3061094153 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcXzufiIdTestFactory.java @@ -0,0 +1,23 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.UUID; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId.Builder; + +public class GrpcXzufiIdTestFactory { + public static final String ORGANISATIONS_EINHEIT_ID = UUID.randomUUID().toString(); + public static final String SCHEME_AGENCY_ID = UUID.randomUUID().toString(); + + public static GrpcXzufiId create() { + return createBuilder(ORGANISATIONS_EINHEIT_ID, SCHEME_AGENCY_ID).build(); + } + + public static GrpcXzufiId create(String id, String schemeAgencyId) { + return createBuilder(id, schemeAgencyId).build(); + } + + public static Builder createBuilder(String id, String schemeAgencyId) { + return GrpcXzufiId.newBuilder().setId(id).setSchemeAgencyId(schemeAgencyId); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcZustaendigkeitTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcZustaendigkeitTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..f3d14432adde69cdbf395ed58bc988befdbd0238 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/GrpcZustaendigkeitTestFactory.java @@ -0,0 +1,22 @@ +package de.ozgcloud.alfa.collaboration; + +import java.util.UUID; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcZustaendigkeit; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcZustaendigkeit.Builder; + +public class GrpcZustaendigkeitTestFactory { + public static final GrpcXzufiId ID = GrpcXzufiIdTestFactory.create(); + public static final String GEBIET_ID = UUID.randomUUID().toString(); + + public static GrpcZustaendigkeit create() { + return createBuilder().build(); + } + + private static Builder createBuilder() { + return GrpcZustaendigkeit.newBuilder() + .setXzufiId(ID) + .setGebietId(GEBIET_ID); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..1edb729801079868c6a347d885ff88d9e659bec3 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitControllerTest.java @@ -0,0 +1,236 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.thedeanda.lorem.LoremIpsum; + +import lombok.SneakyThrows; + +class OrganisationsEinheitControllerTest { + + @InjectMocks + private OrganisationsEinheitController controller; + + @Mock + private OrganisationsEinheitService service; + + @Mock + private OrganisationsEinheitModelAssembler assembler; + + @Mock + private OrganisationsEinheitHeaderModelAssembler headerModelAssembler; + + private MockMvc mockMvc; + + @BeforeEach + void initTest() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Nested + class TestGetById { + + private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create(); + + @BeforeEach + void mockService() { + when(service.getById(OrganisationsEinheitTestFactory.ID)).thenReturn(organisationsEinheit); + } + + @SneakyThrows + @Test + void shouldCallOrganisationsEinheitService() { + performRequest(); + + verify(service).getById(OrganisationsEinheitTestFactory.ID); + } + + @Test + void shouldCallAssembler() { + performRequest(); + + verify(assembler).toModel(organisationsEinheit); + } + + @SneakyThrows + @Test + void shouldReturnStatusOk() { + when(assembler.toModel(organisationsEinheit)).thenReturn(EntityModel.of(organisationsEinheit)); + + var response = performRequest(); + + response.andExpect(status().isOk()); + } + + @SneakyThrows + @Test + void shouldHaveOrganisationsEinheitId() { + when(assembler.toModel(organisationsEinheit)).thenReturn(EntityModel.of(organisationsEinheit)); + + var response = performRequest(); + + response.andExpect(jsonPath("$.id").value(OrganisationsEinheitTestFactory.ID)); + } + + @SneakyThrows + @Test + void shouldHaveOrganisationsEinheitXzufiId() { + when(assembler.toModel(organisationsEinheit)).thenReturn(EntityModel.of(organisationsEinheit)); + + var response = performRequest(); + + response.andExpect(jsonPath("$.xzufiId.id").value(XzufiIdTestFactory.ID)); + } + + @SneakyThrows + @Test + void shouldHaveOrganisationsEinheitXzufiSchemeAgencyId() { + when(assembler.toModel(organisationsEinheit)).thenReturn(EntityModel.of(organisationsEinheit)); + + var response = performRequest(); + + response.andExpect(jsonPath("$.xzufiId.schemeAgencyId").value(XzufiIdTestFactory.SCHEME_AGENCY_ID)); + } + + @SneakyThrows + private ResultActions performRequest() { + return mockMvc.perform(get(OrganisationsEinheitController.PATH + "/" + OrganisationsEinheitTestFactory.ID)); + } + } + + @Nested + class TestSearch { + + private final OrganisationsEinheitHeader organisationsEinheitHeader1 = OrganisationsEinheitHeaderTestFactory.create(); + private final OrganisationsEinheitHeader organisationsEinheitHeader2 = OrganisationsEinheitHeaderTestFactory.createBuilder() + .name(LoremIpsum.getInstance().getName()) + .build(); + private final String searchBy = LoremIpsum.getInstance().getWords(5); + private final List<OrganisationsEinheitHeader> organisationsEinheitHeaders = List.of(organisationsEinheitHeader1, + organisationsEinheitHeader2); + + @BeforeEach + void setUpMocks() { + when(service.searchOrganisationsEinheiten(searchBy)).thenReturn(Stream.of(organisationsEinheitHeader1, organisationsEinheitHeader2)); + when(headerModelAssembler.toCollectionModel(organisationsEinheitHeaders)) + .thenReturn( + CollectionModel.of(List.of(EntityModel.of(organisationsEinheitHeader1), EntityModel.of(organisationsEinheitHeader2)))); + } + + @Test + void shouldCallService() { + performRequest(); + + verify(service).searchOrganisationsEinheiten(searchBy); + } + + @Test + void shouldCallModelAssembler() { + performRequest(); + + verify(headerModelAssembler).toCollectionModel(organisationsEinheitHeaders); + } + + @SneakyThrows + @Test + void shouldReturnStatusOk() { + var response = performRequest(); + + response.andExpect(status().isOk()); + } + + @Test + @SneakyThrows + void shouldHaveFirstOrganisationsEinheitName() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[0].name").value(organisationsEinheitHeader1.getName())); + } + + @Test + @SneakyThrows + void shouldHaveFirstOrganisationsEinheitStrasse() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[0].anschrift.strasse").value(organisationsEinheitHeader1.getAnschrift().getStrasse())); + } + + @Test + @SneakyThrows + void shouldHaveFirstOrganisationsEinheitHausnummer() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[0].anschrift.hausnummer").value(organisationsEinheitHeader1.getAnschrift().getHausnummer())); + } + + @Test + @SneakyThrows + void shouldHaveFirstOrganisationsEinheitPlz() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[0].anschrift.plz").value(organisationsEinheitHeader1.getAnschrift().getPlz())); + } + + @Test + @SneakyThrows + void shouldHaveFirstOrganisationsEinheitOrt() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[0].anschrift.ort").value(organisationsEinheitHeader1.getAnschrift().getOrt())); + } + + @Test + @SneakyThrows + void shouldHaveSecondOrganisationsEinheitName() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[1].name").value(organisationsEinheitHeader2.getName())); + } + + @Test + @SneakyThrows + void shouldHaveSecondOrganisationsEinheitStrasse() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[1].anschrift.strasse").value(organisationsEinheitHeader2.getAnschrift().getStrasse())); + } + + @Test + @SneakyThrows + void shouldHaveSecondOrganisationsEinheitHausnummer() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[1].anschrift.hausnummer").value(organisationsEinheitHeader2.getAnschrift().getHausnummer())); + } + + @Test + @SneakyThrows + void shouldHaveSecondOrganisationsEinheitPlz() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[1].anschrift.plz").value(organisationsEinheitHeader2.getAnschrift().getPlz())); + } + + @Test + @SneakyThrows + void shouldHaveSecondOrganisationsEinheitOrt() { + var response = performRequest(); + response.andExpect(jsonPath("$.content[1].anschrift.ort").value(organisationsEinheitHeader2.getAnschrift().getOrt())); + } + + @SneakyThrows + private ResultActions performRequest() { + return mockMvc.perform(get(OrganisationsEinheitController.PATH) + .param("searchBy", searchBy)).andExpect(status().isOk()); + + } + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapperTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..a259f026c0a25df609a79cff423c8e51b0f3a339 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderMapperTest.java @@ -0,0 +1,23 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +class OrganisationsEinheitHeaderMapperTest { + + private final OrganisationsEinheitHeaderMapper mapper = Mappers.getMapper(OrganisationsEinheitHeaderMapper.class); + + @Nested + class TestFromGrpc { + + @Test + void shouldMap() { + var organisationsEinheitHeader = mapper.fromGrpc(GrpcOrganisationsEinheitTestFactory.create()); + + assertThat(organisationsEinheitHeader).usingRecursiveComparison().isEqualTo(OrganisationsEinheitHeaderTestFactory.create()); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssemblerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..a876719e5ea939789c054347b8bdc878115a301b --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderModelAssemblerTest.java @@ -0,0 +1,71 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Spy; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; + +class OrganisationsEinheitHeaderModelAssemblerTest { + + @Spy + private OrganisationsEinheitHeaderModelAssembler assembler; + + @Nested + class TestToModel { + + @Test + void shouldHaveSelfLink() { + var model = assembler.toModel(OrganisationsEinheitHeaderTestFactory.create()); + + assertHaveSelfLink(model); + } + + private void assertHaveSelfLink(EntityModel<OrganisationsEinheitHeader> model) { + assertThat(model.getLink(IanaLinkRelations.SELF_VALUE)).isPresent().get().extracting(Link::getHref) + .isEqualTo(OrganisationsEinheitController.PATH + "/" + OrganisationsEinheitHeaderTestFactory.ID); + } + } + + @Nested + class TestToCollectionModel { + + private OrganisationsEinheitHeader organisationsEinheitHeader = OrganisationsEinheitHeaderTestFactory.create(); + + @Test + void shouldCallToModel() { + callAssembler(); + + verify(assembler).toModel(organisationsEinheitHeader); + } + + @Test + void shouldHaveSelfLink() { + var model = callAssembler(); + + assertThat(model.getLink(IanaLinkRelations.SELF_VALUE)).isPresent().get().extracting(Link::getHref) + .isEqualTo(OrganisationsEinheitController.PATH); + } + + @Test + void shouldContainEntityModel() { + var entityModel = EntityModel.of(organisationsEinheitHeader); + doReturn(entityModel).when(assembler).toModel(organisationsEinheitHeader); + + var model = callAssembler(); + + assertThat(model).containsExactly(entityModel); + } + + private CollectionModel<EntityModel<OrganisationsEinheitHeader>> callAssembler() { + return assembler.toCollectionModel(List.of(organisationsEinheitHeader)); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..4eef705ebdb94c9d108aab2312d070d6aa63f2ce --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitHeaderTestFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den + * Ministerpräsidenten des Landes Schleswig-Holstein + * Staatskanzlei + * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ +package de.ozgcloud.alfa.collaboration; + +import java.util.UUID; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.alfa.collaboration.OrganisationsEinheitHeader.OrganisationsEinheitHeaderBuilder;; + +public class OrganisationsEinheitHeaderTestFactory { + public static final String ID = UUID.randomUUID().toString(); + public static final String NAME = LoremIpsum.getInstance().getName(); + public static final Anschrift ANSCHRIFT = AnschriftTestFactory.create(); + + public static OrganisationsEinheitHeader create() { + return createBuilder().build(); + } + + public static OrganisationsEinheitHeaderBuilder createBuilder() { + return OrganisationsEinheitHeader.builder() + .id(ID) + .name(NAME) + .anschrift(ANSCHRIFT); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapperTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapperTest.java new file mode 100644 index 0000000000000000000000000000000000000000..1dcce17665e45327791fdecbe19ea3b4eaf4be01 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitMapperTest.java @@ -0,0 +1,23 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +class OrganisationsEinheitMapperTest { + + private final OrganisationsEinheitMapper mapper = Mappers.getMapper(OrganisationsEinheitMapper.class); + + @Nested + class TestFromGrpc { + + @Test + void shouldMap() { + var organisationsEinheit = mapper.fromGrpc(GrpcOrganisationsEinheitTestFactory.create()); + + assertThat(organisationsEinheit).usingRecursiveComparison().isEqualTo(OrganisationsEinheitTestFactory.create()); + } + } +} \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssemblerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..fba797d96ceb4bfb9d9ea85b39e0600c9b10b6dd --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitModelAssemblerTest.java @@ -0,0 +1,32 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Spy; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.web.util.UriComponentsBuilder; + +class OrganisationsEinheitModelAssemblerTest { + + @Spy + private OrganisationsEinheitModelAssembler assembler; + + @Nested + class TestToModel { + + @Test + void shouldHaveSelfLink() { + var expectedHref = UriComponentsBuilder.fromUriString(OrganisationsEinheitController.PATH) + .pathSegment(OrganisationsEinheitTestFactory.ID) + .build().toString(); + + var model = assembler.toModel(OrganisationsEinheitTestFactory.create()); + + assertThat(model.getLink(IanaLinkRelations.SELF_VALUE)).isPresent().get().extracting(Link::getHref) + .isEqualTo(expectedHref); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b8c151afaa513c5026966501071c4719c60d21fe --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitRemoteServiceTest.java @@ -0,0 +1,172 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetRequest; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitGetResponse; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitSearchRequest; +import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheitSearchResponse; +import de.ozgcloud.zufi.grpc.organisationseinheit.OrganisationsEinheitServiceGrpc.OrganisationsEinheitServiceBlockingStub; + +class OrganisationsEinheitRemoteServiceTest { + + @Spy + @InjectMocks + private OrganisationsEinheitRemoteService service; + + @Mock + private OrganisationsEinheitServiceBlockingStub serviceStub; + + @Mock + private OrganisationsEinheitHeaderMapper organisationsEinheitHeaderMapper; + @Mock + private OrganisationsEinheitMapper organisationsEinheitMapper; + + @Nested + class TestSearch { + + private final String searchBy = LoremIpsum.getInstance().getWords(5); + private final GrpcOrganisationsEinheitSearchResponse grpcOrganisationsEinheitSearchResponse = GrpcOrganisationsEinheitSearchResponseTestFactory + .create(); + + @BeforeEach + void setUpMocks() { + when(serviceStub.search(argThat(requestContainsSearchBy()))).thenReturn(grpcOrganisationsEinheitSearchResponse); + } + + @Test + void shouldCallGrpcStub() { + callService(); + + verify(serviceStub).search(argThat(requestContainsSearchBy())); + } + + @Test + void shouldCallMapper() { + callService().toList(); + + verify(organisationsEinheitHeaderMapper).fromGrpc(GrpcOrganisationsEinheitSearchResponseTestFactory.ORGANISATIONS_EINHEIT); + } + + @Test + void shouldReturnMappedOrganisationsEinheitHeader() { + var organisationsEinheitHeader = OrganisationsEinheitHeaderTestFactory.create(); + when(organisationsEinheitHeaderMapper.fromGrpc(GrpcOrganisationsEinheitSearchResponseTestFactory.ORGANISATIONS_EINHEIT)).thenReturn( + organisationsEinheitHeader); + + var organisationsEinheitHeaders = callService(); + + assertThat(organisationsEinheitHeaders).containsExactly(organisationsEinheitHeader); + } + + private Stream<OrganisationsEinheitHeader> callService() { + return service.search(searchBy); + } + + private ArgumentMatcher<GrpcOrganisationsEinheitSearchRequest> requestContainsSearchBy() { + return request -> request.getSearchBy().equals(searchBy); + } + } + + @Nested + class TestGetById { + + private final GrpcOrganisationsEinheitGetRequest request = GrpcOrganisationsEinheitGetRequestTestFactory.create(); + private final GrpcOrganisationsEinheitGetResponse response = GrpcOrganisationsEinheitGetResponseTestFactory.create(); + private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create(); + + @BeforeEach + void setUp() { + doReturn(request).when(service).buildGetByIdRequest(OrganisationsEinheitTestFactory.ID); + when(serviceStub.getById(request)).thenReturn(response); + doReturn(organisationsEinheit).when(service).getOrganisationsEinheitFromGetByIdResponse(response); + } + + @Test + void shouldBuildRequest() { + callService(); + + verify(service).buildGetByIdRequest(OrganisationsEinheitTestFactory.ID); + } + + @Test + void shouldCallGrpcStub() { + callService(); + + verify(serviceStub).getById(request); + } + + @Test + void shouldBuildResponse() { + callService(); + + verify(service).getOrganisationsEinheitFromGetByIdResponse(response); + } + + @Test + void shouldReturnOrganisationsEinheit() { + var get = service.getById(OrganisationsEinheitTestFactory.ID); + + assertThat(get).isEqualTo(organisationsEinheit); + } + + private OrganisationsEinheit callService() { + return service.getById(OrganisationsEinheitTestFactory.ID); + } + } + + @Nested + class TestBuildGetByIdRequest { + + @Test + void shouldHaveId() { + var request = service.buildGetByIdRequest(OrganisationsEinheitTestFactory.ID); + + assertThat(request.getId()).isEqualTo(OrganisationsEinheitTestFactory.ID); + } + } + + @Nested + class TestGetOrganisationsEinheitFromGetByIdResponse { + + private final GrpcOrganisationsEinheitGetResponse response = GrpcOrganisationsEinheitGetResponseTestFactory.create(); + private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create(); + + @BeforeEach + void setUp() { + when(organisationsEinheitMapper.fromGrpc(response.getOrganisationsEinheit())).thenReturn(organisationsEinheit); + } + + @Test + void shouldCallMapper() { + callService(); + + verify(organisationsEinheitMapper).fromGrpc(response.getOrganisationsEinheit()); + } + + @Test + void shouldReturnOrgnisationsEinheit() { + var built = callService(); + + assertThat(built).isEqualTo(organisationsEinheit); + } + + private OrganisationsEinheit callService() { + return service.getOrganisationsEinheitFromGetByIdResponse(response); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..a574a5e4aa0b7f3bbc3b0f4d90497d68d98b7b00 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitServiceTest.java @@ -0,0 +1,71 @@ +package de.ozgcloud.alfa.collaboration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.thedeanda.lorem.LoremIpsum; + +class OrganisationsEinheitServiceTest { + + @InjectMocks + private OrganisationsEinheitService service; + + @Mock + private OrganisationsEinheitRemoteService remoteService; + + @Nested + class TestGetById { + + @Test + void shouldCallRemoteService() { + service.getById(OrganisationsEinheitTestFactory.ID); + + verify(remoteService).getById(OrganisationsEinheitTestFactory.ID); + } + + @Test + void shouldReturnOrganisationsEinheit() { + var organisationsEinheit = OrganisationsEinheitTestFactory.create(); + when(remoteService.getById(OrganisationsEinheitTestFactory.ID)).thenReturn(organisationsEinheit); + + var returnedOrganisationsEinheit = service.getById(OrganisationsEinheitTestFactory.ID); + + assertThat(returnedOrganisationsEinheit).isEqualTo(organisationsEinheit); + } + } + + @Nested + class TestSearchOrganisationsEinheiten { + + private final String searchBy = LoremIpsum.getInstance().getWords(5); + + @Test + void shouldCallRemoteService() { + callService(); + + verify(remoteService).search(searchBy); + } + + @Test + void shouldReturnOrganisationsEinheiten() { + var organisationsEinheitHeader = OrganisationsEinheitHeaderTestFactory.create(); + when(remoteService.search(searchBy)).thenReturn(Stream.of(organisationsEinheitHeader)); + + var organisationsEinheitHeaders = callService(); + + assertThat(organisationsEinheitHeaders).containsExactly(organisationsEinheitHeader); + } + + private Stream<OrganisationsEinheitHeader> callService() { + return service.searchOrganisationsEinheiten(searchBy); + } + } + +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..59c6f03c06961983f4eefbe169474118e11b1087 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/OrganisationsEinheitTestFactory.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den + * Ministerpräsidenten des Landes Schleswig-Holstein + * Staatskanzlei + * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ +package de.ozgcloud.alfa.collaboration; + +import de.ozgcloud.alfa.collaboration.OrganisationsEinheit.OrganisationsEinheitBuilder; + +public class OrganisationsEinheitTestFactory { + + public static final String ID = OrganisationsEinheitHeaderTestFactory.ID; + public static final XzufiId XZUFI_ID = XzufiIdTestFactory.create(); + + public static OrganisationsEinheit create() { + return createBuilder().build(); + } + + public static OrganisationsEinheitBuilder createBuilder() { + return OrganisationsEinheit.builder() + .id(ID) + .xzufiId(XZUFI_ID); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/XzufiIdTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/XzufiIdTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..49ddf54180fa8074dbc4867fb3b16110797f277a --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/collaboration/XzufiIdTestFactory.java @@ -0,0 +1,17 @@ +package de.ozgcloud.alfa.collaboration; + +public class XzufiIdTestFactory { + + public static final String ID = GrpcXzufiIdTestFactory.ORGANISATIONS_EINHEIT_ID; + public static final String SCHEME_AGENCY_ID = GrpcXzufiIdTestFactory.SCHEME_AGENCY_ID; + + public static XzufiId create() { + return createBuilder().build(); + } + + public static XzufiId.XzufiIdBuilder createBuilder() { + return XzufiId.builder() + .id(ID) + .schemeAgencyId(SCHEME_AGENCY_ID); + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/EntityModelTestFactory.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/EntityModelTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..e280a99cb43da4647f9a8ebcb9cbbd753f80dee3 --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/EntityModelTestFactory.java @@ -0,0 +1,20 @@ +package de.ozgcloud.alfa.common; + +import org.springframework.hateoas.EntityModel; + +import de.ozgcloud.alfa.vorgang.VorgangWithEingang; +import lombok.NoArgsConstructor; + +public class EntityModelTestFactory { + + public static final NullableEntityModel NULLABLE = createNullable(); + + private static NullableEntityModel createNullable() { + return new NullableEntityModel(); + } + + @NoArgsConstructor + private static class NullableEntityModel extends EntityModel<VorgangWithEingang> { + + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandModelAssemblerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandModelAssemblerTest.java index 01c57942b5967158fdf4d50b0e9b7219ae527867..fdf074c51c83c4ad34eb6e1c188f0af89c273dfa 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandModelAssemblerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandModelAssemblerTest.java @@ -76,7 +76,7 @@ class CommandModelAssemblerTest { @Nested class TestOnVorgang { - private final String VORGANG_URL = "/api/vorgangs/" + RELATION_ID; + private static final String VORGANG_URL = "/api/vorgangs/" + RELATION_ID; @ParameterizedTest @EnumSource(mode = Mode.INCLUDE, names = { "FINISHED", "REVOKED" }) @@ -103,7 +103,7 @@ class CommandModelAssemblerTest { @Nested class TestOnForwarding { - private final String FORWARDING_URL = "/api/forwardings?vorgangId=" + VorgangHeaderTestFactory.ID; + private static final String FORWARDING_URL = "/api/forwardings?vorgangId=" + VorgangHeaderTestFactory.ID; @ParameterizedTest @ValueSource(strings = { "FORWARD_SUCCESSFULL", "FORWARD_FAILED", "REDIRECT_VORGANG" }) @@ -119,7 +119,7 @@ class CommandModelAssemblerTest { @Nested class TestOnPostfach { - private final String POSTFACH_URL = "/api/postfachMails?vorgangId=" + VorgangHeaderTestFactory.ID; + private static final String POSTFACH_URL = "/api/postfachMails?vorgangId=" + VorgangHeaderTestFactory.ID; @ParameterizedTest @ValueSource(strings = { "SEND_POSTFACH_MAIL" }) @@ -135,7 +135,7 @@ class CommandModelAssemblerTest { @Nested class TestOnBescheid { - private final String BESCHEID_URL = "/api/bescheids?vorgangId=" + VorgangHeaderTestFactory.ID; + private static final String BESCHEID_URL = "/api/bescheids?vorgangId=" + VorgangHeaderTestFactory.ID; @ParameterizedTest @ValueSource(strings = { "CREATE_BESCHEID", "UPDATE_BESCHEID" }) @@ -152,7 +152,7 @@ class CommandModelAssemblerTest { @Nested class TestOnDocument { - private final String DOCUMENT_URL = "/api/bescheids/documents/" + RELATION_ID; + private static final String DOCUMENT_URL = "/api/bescheids/documents/" + RELATION_ID; @Test void shouldHaveLinkToBescheidDokument() { @@ -166,6 +166,21 @@ class CommandModelAssemblerTest { } } + @DisplayName("on collaboration") + @Nested + class TestOnCollaboration { + + private static final String COLLABORATIONS_URL = "/api/vorgangs/" + VORGANG_ID + "/collaborations"; + + @Test + void shouldHaveLinkToCollaborations() { + var model = toModelWithOrder("CREATE_COLLABORATION_REQUEST"); + + assertThat(model.getLink(CommandModelAssembler.REL_EFFECTED_RESOURCE)).isPresent().get() + .extracting(Link::getHref).isEqualTo(COLLABORATIONS_URL); + } + } + @DisplayName("on unknown Order") @Nested class TestOnUnknownOrder { @@ -223,7 +238,7 @@ class CommandModelAssemblerTest { "UPDATE_ATTACHED_ITEM", "PATCH_ATTACHED_ITEM", "RECEIVE_POSTFACH_NACHRICHT", "VORGANG_LOESCHEN", "DELETE_ATTACHED_ITEM", "VORGANG_ZUM_LOESCHEN_MARKIEREN", "LOESCH_ANFORDERUNG_ZURUECKNEHMEN", "CREATE_BESCHEID", "PROCESS_VORGANG", "SET_AKTENZEICHEN", "DELETE_BESCHEID", "UPDATE_BESCHEID", "CREATE_BESCHEID_DOCUMENT_FROM_FILE", "CREATE_BESCHEID_DOCUMENT", "SEND_BESCHEID", - "UNBEKANNT" }) + "UNBEKANNT", "CREATE_COLLABORATION_REQUEST" }) void shouldBePresentOnOrder(CommandOrder order) { var model = toModelWithOrder(order.name()); diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandServiceTest.java index 2bf962ce6cb343babed90d058cbe4549f6acb1b5..95b37ba1901cbd62a954642ee61d4daf09fe4954 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandServiceTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandServiceTest.java @@ -27,7 +27,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.util.Calendar; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -40,7 +39,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.Spy; import de.ozgcloud.alfa.loeschanforderung.DeleteLoeschAnforderung; @@ -299,99 +297,4 @@ class CommandServiceTest { verify(remoteService).findCommands(VorgangHeaderTestFactory.ID, Optional.of(CommandStatus.FINISHED), Optional.empty()); } } - - @Nested - class TestWaitUntilDone { - - private final Command pendingCommand = CommandTestFactory.createBuilder().status(CommandStatus.PENDING).build(); - private final Command finishedCommand = pendingCommand.toBuilder().status(CommandStatus.FINISHED).build(); - - @Nested - class OnFinishedCommand { - - @Test - void shouldReturnFinishedCommand() { - var resultCommand = service.waitUntilDone(finishedCommand); - - assertThat(resultCommand).isEqualTo(finishedCommand); - } - - @Test - void shouldNotReloadCommand() { - service.waitUntilDone(finishedCommand); - - verify(service, never()).reloadCommand(any()); - } - } - - @Nested - class OnPendingCommand { - - @BeforeEach - void setUp() { - doReturn(finishedCommand).when(service).reloadCommand(pendingCommand.getId()); - } - - @Test - void shouldReloadCommand() { - service.waitUntilDone(pendingCommand); - - verify(service).reloadCommand(pendingCommand.getId()); - } - - @Test - void shouldReturnDoneCommand() { - var resultCommand = service.waitUntilDone(pendingCommand); - - assertThat(resultCommand).isEqualTo(finishedCommand); - } - } - - @Nested - class OnTimeoutExceeded { - - @Mock - private Calendar calendar; - - @Test - void shouldReturnPendingCommand() { - try (MockedStatic<Calendar> calendarMockedStatic = mockStatic(Calendar.class)) { - calendarMockedStatic.when(Calendar::getInstance).thenReturn(calendar); - when(calendar.getTimeInMillis()).thenReturn(0L, 15000L); - - var resultCommand = service.waitUntilDone(pendingCommand); - - assertThat(resultCommand).isEqualTo(pendingCommand); - } - } - - @Test - void shouldReloadPendingCommand() { - doReturn(pendingCommand).when(service).reloadCommand(pendingCommand.getId()); - - try (MockedStatic<Calendar> calendarMockedStatic = mockStatic(Calendar.class)) { - calendarMockedStatic.when(Calendar::getInstance).thenReturn(calendar); - when(calendar.getTimeInMillis()).thenReturn(0L, 0L, 15000L); - - service.waitUntilDone(pendingCommand); - - verify(service).reloadCommand(pendingCommand.getId()); - } - } - - @Test - void shouldReturnPendingCommandAfterReload() { - doReturn(pendingCommand).when(service).reloadCommand(pendingCommand.getId()); - - try (MockedStatic<Calendar> calendarMockedStatic = mockStatic(Calendar.class)) { - calendarMockedStatic.when(Calendar::getInstance).thenReturn(calendar); - when(calendar.getTimeInMillis()).thenReturn(0L, 0L, 15000L); - - var resultCommand = service.waitUntilDone(pendingCommand); - - assertThat(resultCommand).isEqualTo(pendingCommand); - } - } - } - } } \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandStatusTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandStatusTest.java deleted file mode 100644 index f91dc7028af4ca954d1dfc9fae1e70affbdcc066..0000000000000000000000000000000000000000 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandStatusTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package de.ozgcloud.alfa.common.command; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.EnumSource.Mode; - -class CommandStatusTest { - - @Nested - class TestIsNotDone { - - @ParameterizedTest - @EnumSource(names = { "PENDING", "REVOKE_PENDING" }) - void shouldReturnTrue(CommandStatus status) { - var istNotDone = status.isNotDone(); - - assertThat(istNotDone).isTrue(); - - } - - @ParameterizedTest - @EnumSource(names = { "PENDING", "REVOKE_PENDING" }, mode = Mode.EXCLUDE) - void shouldReturnFalse(CommandStatus status) { - var istNotDone = status.isNotDone(); - - assertThat(istNotDone).isFalse(); - } - - } - -} \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTest.java deleted file mode 100644 index c838d402226703a6133dc5f8a65b79e7d911373e..0000000000000000000000000000000000000000 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/command/CommandTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package de.ozgcloud.alfa.common.command; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.EnumSource.Mode; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; - -import com.thedeanda.lorem.LoremIpsum; - -class CommandTest { - - @Nested - class TestIsFinishedSuccessfully { - - @Test - void shouldReturnTrue() { - var command = CommandTestFactory.createBuilder().status(CommandStatus.FINISHED).build(); - - var isDoneSuccessfully = command.isFinishedSuccessfully(); - - assertThat(isDoneSuccessfully).isTrue(); - } - - @ParameterizedTest - @EnumSource(names = "FINISHED", mode = Mode.EXCLUDE) - void shouldReturnFalseOnStatusNotFinished(CommandStatus commandStatus) { - var command = CommandTestFactory.createBuilder().status(commandStatus).build(); - - var isDoneSuccessfully = command.isFinishedSuccessfully(); - - assertThat(isDoneSuccessfully).isFalse(); - } - - @ParameterizedTest - @EnumSource - void shouldReturnFalseOnErrorMessage(CommandStatus commandStatus) { - var command = CommandTestFactory.createBuilder().status(commandStatus).errorMessage(LoremIpsum.getInstance().getWords(1)).build(); - - var isDoneSuccessfully = command.isFinishedSuccessfully(); - - assertThat(isDoneSuccessfully).isFalse(); - } - - } - - @Nested - class TestIsNotDone { - - @Mock - private CommandStatus commandStatus; - private Command command; - - @BeforeEach - void setUp() { - command = CommandTestFactory.createBuilder().status(commandStatus).build(); - } - - @Test - void shouldCallIsNotDone() { - command.isNotDone(); - - verify(commandStatus).isNotDone(); - } - - @ParameterizedTest - @ValueSource(booleans = { true, false }) - void shouldReturnIsNotDone(boolean isNotDone) { - when(commandStatus.isNotDone()).thenReturn(isNotDone); - - var result = command.isNotDone(); - - assertThat(result).isEqualTo(isNotDone); - } - - } - -} \ No newline at end of file diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/user/CurrentUserServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/user/CurrentUserServiceTest.java index e363dc185c06592f6e7e11e35f25c131bd6a83d6..a478cf2de0cf24b12bec1c1279be4b26c3d9a2d5 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/common/user/CurrentUserServiceTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/user/CurrentUserServiceTest.java @@ -35,8 +35,10 @@ import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.oauth2.jwt.Jwt; import de.ozgcloud.alfa.JwtTestFactory; @@ -44,7 +46,12 @@ import de.ozgcloud.alfa.JwtTestFactory; class CurrentUserServiceTest { @Spy + @InjectMocks private CurrentUserService service; + @Mock + private UserService userService; + @Mock + private RoleHierarchy roleHierarchy; @Nested class TestGetOrganisationseinheit { diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailServiceTest.java index 85c964af5fddcf578e6074bec1c596245b121d0b..f31e143f6a0b1723b19a82771fced255d102340f 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailServiceTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/postfach/PostfachMailServiceTest.java @@ -556,7 +556,7 @@ class PostfachMailServiceTest { } @Test - @DisplayName("reply not allowed for given ServiceKonto-type") + @DisplayName("reply not allowed if postfach not configured") void shouldReturnFalseIfPostfachNotConfigured() { setPostfachConfigGroup(PostfachConfigGroupTestFactory.createBuilder().postfachConfigs(List.of()).build()); doReturn(false).when(service).isPostfachConfigured(); @@ -612,6 +612,17 @@ class PostfachMailServiceTest { assertThat(replyAllowed).isTrue(); } + @Test + @DisplayName("reply allowed for given lower case ServiceKonto-type") + void shouldReturnTrueIfReplyIsAllowedForLowerCaseServiceKontoType() { + setPostfachConfigGroup(PostfachConfigGroupTestFactory.create()); + doReturn(true).when(service).isPostfachConfigured(); + + var replyAllowed = service.isReplyAllowed(PostfachConfigTestFactory.TYPE.toLowerCase()); + + assertThat(replyAllowed).isTrue(); + } + @Test void shouldReturnTrueIfAlwaysAllowed() { when(featureToggleProperties.isReplyAlwaysAllowed()).thenReturn(true); diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandByVorgangControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandByVorgangControllerTest.java index ef4a43f95f4ed8458d9be2f37bb190e32bd87e84..f8b56e2cb33333ee2e01cd40d411f201632f79d4 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandByVorgangControllerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandByVorgangControllerTest.java @@ -44,13 +44,8 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import de.ozgcloud.alfa.common.binaryfile.BinaryFileTestFactory; -import de.ozgcloud.alfa.common.command.Command; -import de.ozgcloud.alfa.common.command.CommandController.CommandByRelationController; -import de.ozgcloud.alfa.common.command.CommandStatus; import de.ozgcloud.alfa.common.command.CommandTestFactory; -import de.ozgcloud.alfa.common.command.CreateCommand; import de.ozgcloud.alfa.common.command.LegacyOrder; -import de.ozgcloud.alfa.common.user.CurrentUserService; import de.ozgcloud.alfa.vorgang.VorgangHeaderTestFactory; import de.ozgcloud.alfa.wiedervorlage.WiedervorlageCommandController.WiedervorlageCommandByVorgangController; import lombok.SneakyThrows; @@ -60,15 +55,10 @@ class WiedervorlageCommandByVorgangControllerTest { @Spy @InjectMocks private WiedervorlageCommandByVorgangController controller; - @Mock - private CommandByRelationController commandByRelationController; - @Mock - private CurrentUserService userService; + @Mock private WiedervorlageService service; - @Captor - private ArgumentCaptor<CreateCommand> createCommandCaptor; private MockMvc mockMvc; @BeforeEach @@ -82,15 +72,10 @@ class WiedervorlageCommandByVorgangControllerTest { @Captor private ArgumentCaptor<Wiedervorlage> wiedervorlageCaptor; - private Command createCommand; - private Command doneCommand; @BeforeEach void mockUserService() { - createCommand = CommandTestFactory.create(); - doneCommand = createCommand.toBuilder().status(CommandStatus.FINISHED).build(); - when(service.createWiedervorlage(any(), any())).thenReturn(createCommand); - when(service.updateNextFrist(createCommand, VorgangHeaderTestFactory.ID)).thenReturn(doneCommand); + when(service.createWiedervorlage(any(), any())).thenReturn(CommandTestFactory.create()); } @Nested @@ -105,10 +90,14 @@ class WiedervorlageCommandByVorgangControllerTest { } @Test - void shouldUpdateNextFristOnSuccessfullyDoneCommand() { + void shouldCallServiceToUpdateNextFrist() { doRequest(); - verify(service).updateNextFrist(createCommand, VorgangHeaderTestFactory.ID); + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), wiedervorlageCaptor.capture()); + assertThat(wiedervorlageCaptor.getValue()) + .usingRecursiveComparison() + .comparingOnlyFields("betreff", "beschreibung", "frist", "attachments") + .isEqualTo(WiedervorlageTestFactory.create()); } @SneakyThrows diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandControllerTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandControllerTest.java index 198f7a00535cfc7dbcc73c53fa65f3d826a2225d..4ab1dcc715ab7ea4234a59cf2cd79dc30f9d57bb 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandControllerTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageCommandControllerTest.java @@ -35,6 +35,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; @@ -46,7 +48,6 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import de.ozgcloud.alfa.common.binaryfile.FileId; import de.ozgcloud.alfa.common.command.Command; import de.ozgcloud.alfa.common.command.CommandOrder; -import de.ozgcloud.alfa.common.command.CommandStatus; import de.ozgcloud.alfa.common.command.CommandTestFactory; import de.ozgcloud.alfa.common.command.CreateCommand; import de.ozgcloud.alfa.common.command.LegacyOrder; @@ -78,18 +79,15 @@ class WiedervorlageCommandControllerTest { @Nested class ControllerMethods { - private Command createCommand; - private Command doneCommand; + @Captor + private ArgumentCaptor<Wiedervorlage> wiedervorlageCaptor; @BeforeEach void init() { when(service.getById(any())).thenReturn(WiedervorlageTestFactory.create()); - createCommand = CommandTestFactory.createBuilder() + when(service.editWiedervorlage(any(), any(), anyLong())).thenReturn(CommandTestFactory.createBuilder() .order(CommandOrder.UPDATE_ATTACHED_ITEM.name()) - .body(WiedervorlageTestFactory.createAsMap()).build(); - doneCommand = createCommand.toBuilder().status(CommandStatus.FINISHED).build(); - when(service.editWiedervorlage(any(), any(), anyLong())).thenReturn(createCommand); - when(service.updateNextFrist(createCommand, VorgangHeaderTestFactory.ID)).thenReturn(doneCommand); + .body(WiedervorlageTestFactory.createAsMap()).build()); } @SneakyThrows @@ -101,10 +99,14 @@ class WiedervorlageCommandControllerTest { } @Test - void shouldUpdateNextFristOnSuccessfullyDoneCommand() { + void shouldCallServiceUpdateNextFrist() { doRequest(); - verify(service).updateNextFrist(createCommand, VorgangHeaderTestFactory.ID); + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), wiedervorlageCaptor.capture()); + assertThat(wiedervorlageCaptor.getValue()) + .usingRecursiveComparison() + .comparingOnlyFields("betreff", "beschreibung", "frist", "attachments") + .isEqualTo(WiedervorlageTestFactory.create()); } @SneakyThrows @@ -152,6 +154,9 @@ class WiedervorlageCommandControllerTest { @Nested class TestCreateCommand { + @Captor + private ArgumentCaptor<Wiedervorlage> wiedervorlageArgumentCaptor; + @DisplayName("for order 'erledigen'") @Nested class TestOnErledigenOrder { @@ -162,6 +167,23 @@ class WiedervorlageCommandControllerTest { verify(service).erledigen(any(Wiedervorlage.class)); } + + @Test + void shouldUpdateNextFrist() { + callCreateCommand(LegacyOrder.WIEDERVORLAGE_ERLEDIGEN); + + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), any(Wiedervorlage.class)); + } + + @Test + void shouldSetWiedervorlageAsDone() { + callCreateCommand(LegacyOrder.WIEDERVORLAGE_ERLEDIGEN); + + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), wiedervorlageArgumentCaptor.capture()); + assertThat(wiedervorlageArgumentCaptor.getValue()) + .usingRecursiveComparison() + .isEqualTo(WiedervorlageTestFactory.createBuilder().done(true).build()); + } } @DisplayName("for order 'wiedereroeffnen'") @@ -174,6 +196,23 @@ class WiedervorlageCommandControllerTest { verify(service).wiedereroeffnen(any(Wiedervorlage.class)); } + + @Test + void shouldUpdateNextFrist() { + callCreateCommand(LegacyOrder.WIEDERVORLAGE_WIEDEREROEFFNEN); + + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), any(Wiedervorlage.class)); + } + + @Test + void shouldSetWiedervorlageAsOpen() { + callCreateCommand(LegacyOrder.WIEDERVORLAGE_WIEDEREROEFFNEN); + + verify(service).updateNextFrist(eq(VorgangHeaderTestFactory.ID), wiedervorlageArgumentCaptor.capture()); + assertThat(wiedervorlageArgumentCaptor.getValue()) + .usingRecursiveComparison() + .isEqualTo(WiedervorlageTestFactory.createBuilder().done(false).build()); + } } @DisplayName("for order 'edit'") @@ -188,6 +227,16 @@ class WiedervorlageCommandControllerTest { eq(WiedervorlageTestFactory.VERSION)); } + @Test + void shouldUpdateNextFrist() { + var wiedervorlage = WiedervorlageTestFactory.create(); + doReturn(wiedervorlage).when(controller).updateWiedervorlageByCommand(any(), any()); + + callCreateCommand(LegacyOrder.EDIT_WIEDERVORLAGE); + + verify(service).updateNextFrist(VorgangHeaderTestFactory.ID, wiedervorlage); + } + @DisplayName("update wiedervorlage by given command") @Nested class TestUpdateWiedervorlageByCommand { diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageServiceTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageServiceTest.java index ae7f2e55675bd0ee63bb76f7a1b175b3bec39534..b8ab4843ea90a7148c175f4349f6a23ba62a6d39 100644 --- a/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageServiceTest.java +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/wiedervorlage/WiedervorlageServiceTest.java @@ -38,7 +38,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -47,7 +46,6 @@ import org.mockito.Spy; import de.ozgcloud.alfa.common.attacheditem.VorgangAttachedItemService; import de.ozgcloud.alfa.common.command.Command; import de.ozgcloud.alfa.common.command.CommandService; -import de.ozgcloud.alfa.common.command.CommandStatus; import de.ozgcloud.alfa.common.command.CommandTestFactory; import de.ozgcloud.alfa.common.user.CurrentUserService; import de.ozgcloud.alfa.common.user.UserProfileTestFactory; @@ -108,14 +106,14 @@ class WiedervorlageServiceTest { } @Test - void shouldSetCreatedAt() { + void shouldSetCreatedAt() throws Exception { var wiedervorlage = callAddCreated(); assertThat(wiedervorlage.getCreatedAt()).isNotNull().isCloseTo(ZonedDateTime.now(), within(2, ChronoUnit.SECONDS)); } @Test - void shouldSetCreatedBy() { + void shouldSetCreatedBy() throws Exception { var wiedervorlage = callAddCreated(); assertThat(wiedervorlage.getCreatedBy()).isEqualTo(UserProfileTestFactory.ID.toString()); @@ -173,150 +171,95 @@ class WiedervorlageServiceTest { } @Nested - class TestDoUpdateNextFrist { - - @Nested - class ServiceMethod { - - @BeforeEach - void mockService() { - when(remoteService.findByVorgangId(any())).thenReturn(Stream.of(WiedervorlageTestFactory.create())); - } - - @Test - void shoulDoCalculation() { - callUpdateNextFrist(); - - verify(service).calculateNextFrist(ArgumentMatchers.<Stream<Wiedervorlage>>any()); - } - - @Test - void shouldCallFindByVorgangId() { - callUpdateNextFrist(); - - verify(service).findByVorgangId(VorgangHeaderTestFactory.ID); - } - - @Test - void shouldCallRemoteService() { - doReturn(Optional.of(WiedervorlageTestFactory.FRIST)).when(service).calculateNextFrist(any()); + class TestUpdateNextFrist { - callUpdateNextFrist(); + private final Wiedervorlage wiedervorlage = WiedervorlageTestFactory.create(); + private final Wiedervorlage foundWiedervorlage = WiedervorlageTestFactory.create(); - verify(remoteService).updateNextFrist(VorgangHeaderTestFactory.ID, Optional.of(WiedervorlageTestFactory.FRIST)); - } + @Captor + private ArgumentCaptor<Stream<Wiedervorlage>> allWiedervorlagen; - private void callUpdateNextFrist() { - service.doUpdateNextFrist(VorgangHeaderTestFactory.ID); - } + @BeforeEach + void mockService() { + when(remoteService.findByVorgangId(VorgangHeaderTestFactory.ID)).thenReturn(Stream.of(foundWiedervorlage)); + doReturn(Optional.of(WiedervorlageTestFactory.FRIST)).when(service).calculateNextFrist(any()); } - @Nested - class Calculation { + @Test + void shouldDoCalculation() { + callUpdateNextFrist(); - @Test - void shouldReturnNullOnAllDone() { - var wiedervorlage = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(true).build(); + verify(service).calculateNextFrist(any()); + } - var nextFrist = calculateNextFrist(Stream.of(wiedervorlage)); + @Test + void shouldAddWiedervorlageToFoundWiedervorlagen() { + callUpdateNextFrist(); - assertThat(nextFrist).isEmpty(); - } + verify(service).calculateNextFrist(allWiedervorlagen.capture()); + assertThat(allWiedervorlagen.getValue()).containsExactly(wiedervorlage); + } - @Test - void shouldReturnEarliestFrist() { - var fristPast2Days = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(2, ChronoUnit.DAYS)).done(false) - .build(); - var fristPast1Day = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(1, ChronoUnit.DAYS)).done(false).build(); - var fristFuture1Day = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(false) - .build(); - var fristFuture2Days = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(2, ChronoUnit.DAYS)).done(false) - .build(); - - var nextFrist = calculateNextFrist(Stream.of(fristPast2Days, fristPast1Day, fristFuture1Day, fristFuture2Days)); - - assertThat(nextFrist).contains(LocalDate.now().minus(2, ChronoUnit.DAYS)); - } + @Test + void shouldCallFindByVorgangId() { + callUpdateNextFrist(); - @Test - void shouldReturnFristIgnoringDone() { - var fristPast1DayNotDone = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(false) - .build(); - var fristPast1DayDone = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(1, ChronoUnit.DAYS)).done(true) - .build(); + verify(service).findByVorgangId(VorgangHeaderTestFactory.ID); + } - var nextFrist = calculateNextFrist(Stream.of(fristPast1DayNotDone, fristPast1DayDone)); + @Test + void shouldCallRemoteService() { + callUpdateNextFrist(); - assertThat(nextFrist).contains(LocalDate.now().plus(1, ChronoUnit.DAYS)); - } + verify(remoteService).updateNextFrist(VorgangHeaderTestFactory.ID, Optional.of(WiedervorlageTestFactory.FRIST)); + } - private Optional<LocalDate> calculateNextFrist(Stream<Wiedervorlage> wiedervorlagen) { - return service.calculateNextFrist(wiedervorlagen); - } + private void callUpdateNextFrist() { + service.updateNextFrist(VorgangHeaderTestFactory.ID, wiedervorlage); } } @Nested - class TestUpdateNextFrist { + class TestCalculateNextFrist { @Test - void shouldWaitUntilCommandDone() { - var pendingCommand = CommandTestFactory.createBuilder().status(CommandStatus.PENDING).build(); - var command = CommandTestFactory.create(); - when(commandService.waitUntilDone(command)).thenReturn(pendingCommand); + void shouldReturnNullOnAllDone() { + var wiedervorlage = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(true).build(); - service.updateNextFrist(command, VorgangHeaderTestFactory.ID); + var nextFrist = calculateNextFrist(Stream.of(wiedervorlage)); - verify(commandService).waitUntilDone(command); + assertThat(nextFrist).isEmpty(); } @Test - void shouldReturnDoneCommand() { - var doneCommand = CommandTestFactory.createBuilder().status(CommandStatus.FINISHED).build(); - var command = CommandTestFactory.create(); - when(commandService.waitUntilDone(command)).thenReturn(doneCommand); - - var result = service.updateNextFrist(command, VorgangHeaderTestFactory.ID); - - assertThat(result).isEqualTo(doneCommand); + void shouldReturnEarliestFrist() { + var fristPast2Days = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(2, ChronoUnit.DAYS)).done(false) + .build(); + var fristPast1Day = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(1, ChronoUnit.DAYS)).done(false).build(); + var fristFuture1Day = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(false) + .build(); + var fristFuture2Days = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(2, ChronoUnit.DAYS)).done(false) + .build(); + + var nextFrist = calculateNextFrist(Stream.of(fristPast2Days, fristPast1Day, fristFuture1Day, fristFuture2Days)); + + assertThat(nextFrist).contains(LocalDate.now().minus(2, ChronoUnit.DAYS)); } - @Nested - class OnDoneSuccessfullyCommand { - private final Command command = CommandTestFactory.create(); - private final Command doneCommand = CommandTestFactory.createBuilder().status(CommandStatus.FINISHED).build(); - - @BeforeEach - void setUp() { - when(commandService.waitUntilDone(command)).thenReturn(doneCommand); - } + @Test + void shouldReturnFristIgnoringDone() { + var fristPast1DayNotDone = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().plus(1, ChronoUnit.DAYS)).done(false) + .build(); + var fristPast1DayDone = WiedervorlageTestFactory.createBuilder().frist(LocalDate.now().minus(1, ChronoUnit.DAYS)).done(true) + .build(); - @Test - void shouldUpdateNextFrist() { - service.updateNextFrist(command, VorgangHeaderTestFactory.ID); + var nextFrist = calculateNextFrist(Stream.of(fristPast1DayNotDone, fristPast1DayDone)); - verify(service).doUpdateNextFrist(VorgangHeaderTestFactory.ID); - } + assertThat(nextFrist).contains(LocalDate.now().plus(1, ChronoUnit.DAYS)); } - @Nested - class OnNotDoneSuccessfullyCommand { - private final Command command = CommandTestFactory.create(); - private final Command pendingCommand = CommandTestFactory.createBuilder().status(CommandStatus.PENDING).build(); - - @BeforeEach - void setUp() { - when(commandService.waitUntilDone(command)).thenReturn(pendingCommand); - } - - @Test - void shouldNotUpdateNextFrist() { - service.updateNextFrist(command, VorgangHeaderTestFactory.ID); - - verify(service, never()).doUpdateNextFrist(VorgangHeaderTestFactory.ID); - } + private Optional<LocalDate> calculateNextFrist(Stream<Wiedervorlage> wiedervorlagen) { + return service.calculateNextFrist(wiedervorlagen); } - } - } \ No newline at end of file diff --git a/alfa-xdomea/pom.xml b/alfa-xdomea/pom.xml index c141f0be18aa411909614eadcb8a85bc679f4837..4bd9cb9cc3356d4ff4484bfd957150d964526142 100644 --- a/alfa-xdomea/pom.xml +++ b/alfa-xdomea/pom.xml @@ -31,7 +31,7 @@ <parent> <groupId>de.ozgcloud.alfa</groupId> <artifactId>alfa</artifactId> - <version>2.12.0-SNAPSHOT</version> + <version>2.13.0-SNAPSHOT</version> </parent> <artifactId>alfa-xdomea</artifactId> diff --git a/pom.xml b/pom.xml index 334f0fbc2c8cf186962349557be289bcb2724de3..1989d2bfc0f915073d913b26e94eadfed20299bc 100644 --- a/pom.xml +++ b/pom.xml @@ -24,18 +24,20 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>de.ozgcloud.common</groupId> <artifactId>ozgcloud-common-parent</artifactId> - <version>4.3.1</version> + <version>4.3.2</version> </parent> <groupId>de.ozgcloud.alfa</groupId> <artifactId>alfa</artifactId> - <version>2.12.0-SNAPSHOT</version> + <version>2.13.0-SNAPSHOT</version> <name>Alfa Parent</name> <packaging>pom</packaging> @@ -54,6 +56,7 @@ <nachrichten-manager.version>2.7.0</nachrichten-manager.version> <ozgcloud-common-pdf.version>3.0.1</ozgcloud-common-pdf.version> <user-manager.version>2.2.0</user-manager.version> + <zufi-manager.version>1.2.0</zufi-manager.version> <!-- TODO: die Version über ozgcloud-common ziehen --> <jjwt.version>0.11.5</jjwt.version> @@ -103,10 +106,15 @@ <artifactId>vorgang-manager-interface</artifactId> <version>${vorgang-manager.version}</version> </dependency> + <dependency> + <groupId>de.ozgcloud.zufi</groupId> + <artifactId>zufi-manager-interface</artifactId> + <version>${zufi-manager.version}</version> + </dependency> <dependency> <groupId>de.ozgcloud.nachrichten</groupId> <artifactId>nachrichten-manager-interface</artifactId> - <version>2.7.0</version> + <version>${nachrichten-manager.version}</version> </dependency> <dependency> <groupId>de.ozgcloud.vorgang</groupId> @@ -165,4 +173,4 @@ <url>https://nexus.ozg-sh.de/repository/ozg-snapshots/</url> </snapshotRepository> </distributionManagement> -</project> +</project> \ No newline at end of file diff --git a/src/main/helm/templates/deployment.yaml b/src/main/helm/templates/deployment.yaml index b7221ad6bd6407099a01862c2ffb85bb5199d49d..edfb381cc4c6e5a540411f140df4b359727c044d 100644 --- a/src/main/helm/templates/deployment.yaml +++ b/src/main/helm/templates/deployment.yaml @@ -109,6 +109,14 @@ spec: value: {{ ((.Values.ozgcloud).xdomea).behoerdenschluesselUri}} - name: ozgcloud_xdomea_behoerdenschluesselVersion value: {{ ((.Values.ozgcloud).xdomea).behoerdenschluesselVersion | quote }} + - name: grpc_client_zufi-manager_address + value: {{ .Values.zufiManager.address }} + - name: grpc_client_zufi-manager_negotiationType + value: {{ (.Values.zufiManager).grpcClientNegotiationType | default "TLS" }} + {{- if ((.Values.ozgcloud).feature).collaborationEnabled }} + - name: ozgcloud_feature_collaborationEnabled + value: {{ ((.Values.ozgcloud).feature).collaborationEnabled | quote }} + {{- end }} image: "{{ .Values.image.repo }}/{{ .Values.image.name }}:{{ coalesce (.Values.image).tag "latest" }}" imagePullPolicy: Always diff --git a/src/main/helm/templates/network_policy.yaml b/src/main/helm/templates/network_policy.yaml index fde1ca628cf7ea984d4ffceefbdf13f4922c339a..ee37649c47d584833c401a8f68748ba46a4d8fd2 100644 --- a/src/main/helm/templates/network_policy.yaml +++ b/src/main/helm/templates/network_policy.yaml @@ -21,6 +21,18 @@ spec: {{ toYaml . | indent 2 }} {{- end }} egress: +{{- if ((.Values.ozgcloud).feature).collaborationEnabled }} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ required "zufiManager.namespace must be set if zufiManager server is enabled" (.Values.zufiManager).namespace }} + podSelector: + matchLabels: + component: zufi-server + ports: + - port: 9090 + protocol: TCP +{{- end }} - to: - podSelector: matchLabels: diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml index 0e84c312bbfbcf0e243f8b32f2da91f815f353fe..1a13c1ebe2980adc97be000d735850ee5d59b900 100644 --- a/src/main/helm/values.yaml +++ b/src/main/helm/values.yaml @@ -31,7 +31,8 @@ replicaCount: 2 # [default: 2] usermanagerName: user-manager - +zufiManager: + address: zufi-server.zufi:9090 # env: # overrideSpringProfiles: "oc,prod" diff --git a/src/test/helm-linter-values.yaml b/src/test/helm-linter-values.yaml index 51a5840b198286e9e6f323503971c3e56981900f..18f72b2662390d78c5e547e1f7b90d403f8edf7d 100644 --- a/src/test/helm-linter-values.yaml +++ b/src/test/helm-linter-values.yaml @@ -37,3 +37,6 @@ sso: serverUrl: https://sso.company.local imagePullSecret: image-pull-secret + +zufiManager: + address: https://url.url \ No newline at end of file diff --git a/src/test/helm/deployment_collaboration_env_test.yaml b/src/test/helm/deployment_collaboration_env_test.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4cb772d0ee55c2dc51d332ecf415bc1526e2b8b2 --- /dev/null +++ b/src/test/helm/deployment_collaboration_env_test.yaml @@ -0,0 +1,66 @@ +suite: deployment collaboration env +release: + name: alfa + namespace: sh-helm-test +templates: + - templates/deployment.yaml +set: + baseUrl: test.company.local + ozgcloud: + environment: test + bundesland: sh + bezeichner: helm + sso: + serverUrl: https://sso.company.local + imagePullSecret: image-pull-secret +tests: + - it: should have set zufi server address + set: + zufiManager: + address: url://url.url + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: grpc_client_zufi-manager_address + value: url://url.url + - it: should enable collaboration + set: + ozgcloud: + feature: + collaborationEnabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: ozgcloud_feature_collaborationEnabled + value: "true" + - it: should not enable collaboration + set: + ozgcloud: + feature: + collaborationEnabled: false + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: ozgcloud_feature_collaborationEnabled + any: true + - it: should set negotiation type + set: + zufiManager: + grpcClientNegotiationType: PLAINTEXT + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: grpc_client_zufi-manager_negotiationType + value: PLAINTEXT + - it: negotiation type is TLS in standard + set: + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: grpc_client_zufi-manager_negotiationType + value: TLS \ No newline at end of file diff --git a/src/test/helm/network_policy_test.yaml b/src/test/helm/network_policy_test.yaml index af65c804e62b7fb9aebb26f65f2322b536f71550..b1b35ecddd7608c33ed412b7a036829d4e966bb6 100644 --- a/src/test/helm/network_policy_test.yaml +++ b/src/test/helm/network_policy_test.yaml @@ -78,7 +78,7 @@ tests: - port: 8080 egress: - to: - - podSelector: + - podSelector: matchLabels: component: vorgang-manager ports: @@ -223,4 +223,40 @@ tests: dnsServerNamespace: test-dns-server-namespace asserts: - hasDocuments: - count: 1 \ No newline at end of file + count: 1 + + - it: should set egress for zufi if configured + set: + networkPolicy: + ssoPublicIp: 1.1.1.1 + dnsServerNamespace: test-dns-server-namespace + ozgcloud: + feature: + collaborationEnabled: true + zufiManager: + namespace: by-zufi-dev + asserts: + - contains: + path: spec.egress + content: + to: + - podSelector: + matchLabels: + component: zufi-server + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: by-zufi-dev + ports: + - port: 9090 + protocol: TCP + - it: should fail to set egress for zufi if namespace is missing + set: + networkPolicy: + ssoPublicIp: 1.1.1.1 + dnsServerNamespace: test-dns-server-namespace + ozgcloud: + feature: + collaborationEnabled: true + asserts: + - failedTemplate: + errorMessage: zufiManager.namespace must be set if zufiManager server is enabled \ No newline at end of file