From efbbb2c56110a359e9e09ac147ec26374790bcaf Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 29 Mar 2023 09:46:17 +0200 Subject: [PATCH 01/16] Rename BE view->detail --- src/app/billingentity/billing-entity-routing.module.ts | 4 ++-- src/app/billingentity/billing-entity.module.ts | 4 ++-- .../billingentity-detail.component.html} | 0 .../billingentity-detail.component.scss} | 0 .../billingentity-detail.component.ts} | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/app/billingentity/{billingentity-view/billingentity-view.component.html => billingentity-detail/billingentity-detail.component.html} (100%) rename src/app/billingentity/{billingentity-view/billingentity-view.component.scss => billingentity-detail/billingentity-detail.component.scss} (100%) rename src/app/billingentity/{billingentity-view/billingentity-view.component.ts => billingentity-detail/billingentity-detail.component.ts} (81%) diff --git a/src/app/billingentity/billing-entity-routing.module.ts b/src/app/billingentity/billing-entity-routing.module.ts index a66edf2f..426f21c7 100644 --- a/src/app/billingentity/billing-entity-routing.module.ts +++ b/src/app/billingentity/billing-entity-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { BillingEntityComponent } from './billing-entity.component'; -import { BillingentityViewComponent } from './billingentity-view/billingentity-view.component'; +import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; import { KubernetesPermissionGuard } from '../kubernetes-permission.guard'; import { BillingEntityPermissions } from '../types/billing-entity'; import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; @@ -17,7 +17,7 @@ const routes: Routes = [ }, { path: ':name', - component: BillingentityViewComponent, + component: BillingentityDetailComponent, canActivate: [KubernetesPermissionGuard], data: { requiredKubernetesPermissions: [{ ...BillingEntityPermissions, verb: 'list' }], diff --git a/src/app/billingentity/billing-entity.module.ts b/src/app/billingentity/billing-entity.module.ts index c7eee27a..77e25b13 100644 --- a/src/app/billingentity/billing-entity.module.ts +++ b/src/app/billingentity/billing-entity.module.ts @@ -2,12 +2,12 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { BillingEntityComponent } from './billing-entity.component'; import { BillingEntityRoutingModule } from './billing-entity-routing.module'; -import { BillingentityViewComponent } from './billingentity-view/billingentity-view.component'; +import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; import { PanelModule } from 'primeng/panel'; import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; @NgModule({ - declarations: [BillingEntityComponent, BillingentityViewComponent, BillingentityMembersComponent], + declarations: [BillingEntityComponent, BillingentityDetailComponent, BillingentityMembersComponent], imports: [SharedModule, BillingEntityRoutingModule, PanelModule], }) export default class BillingEntityModule {} diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.html b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html similarity index 100% rename from src/app/billingentity/billingentity-view/billingentity-view.component.html rename to src/app/billingentity/billingentity-detail/billingentity-detail.component.html diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.scss b/src/app/billingentity/billingentity-detail/billingentity-detail.component.scss similarity index 100% rename from src/app/billingentity/billingentity-view/billingentity-view.component.scss rename to src/app/billingentity/billingentity-detail/billingentity-detail.component.scss diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.ts b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts similarity index 81% rename from src/app/billingentity/billingentity-view/billingentity-view.component.ts rename to src/app/billingentity/billingentity-detail/billingentity-detail.component.ts index 1507c88d..07cbfc00 100644 --- a/src/app/billingentity/billingentity-view/billingentity-view.component.ts +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts @@ -6,12 +6,12 @@ import { faClose, faWarning } from '@fortawesome/free-solid-svg-icons'; import { BillingEntityCollectionService } from '../../store/billingentity-collection.service'; @Component({ - selector: 'app-billingentity-view', - templateUrl: './billingentity-view.component.html', - styleUrls: ['./billingentity-view.component.scss'], + selector: 'app-billingentity-detail', + templateUrl: './billingentity-detail.component.html', + styleUrls: ['./billingentity-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BillingentityViewComponent implements OnInit { +export class BillingentityDetailComponent implements OnInit { billingEntity$?: Observable; billingEntityName = ''; From fe5b2adcac35ed4afd28e36d017928ad597748d5 Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 29 Mar 2023 09:49:48 +0200 Subject: [PATCH 02/16] Extract BE fields to view component --- .../billingentity/billing-entity.module.ts | 8 ++- .../billingentity-detail.component.html | 57 +------------------ .../billingentity-detail.component.ts | 3 +- .../billingentity-view.component.html | 54 ++++++++++++++++++ .../billingentity-view.component.scss | 0 .../billingentity-view.component.ts | 16 ++++++ 6 files changed, 81 insertions(+), 57 deletions(-) create mode 100644 src/app/billingentity/billingentity-view/billingentity-view.component.html create mode 100644 src/app/billingentity/billingentity-view/billingentity-view.component.scss create mode 100644 src/app/billingentity/billingentity-view/billingentity-view.component.ts diff --git a/src/app/billingentity/billing-entity.module.ts b/src/app/billingentity/billing-entity.module.ts index 77e25b13..be9cbc0f 100644 --- a/src/app/billingentity/billing-entity.module.ts +++ b/src/app/billingentity/billing-entity.module.ts @@ -5,9 +5,15 @@ import { BillingEntityRoutingModule } from './billing-entity-routing.module'; import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; import { PanelModule } from 'primeng/panel'; import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; +import { BillingentityViewComponent } from './billingentity-view/billingentity-view.component'; @NgModule({ - declarations: [BillingEntityComponent, BillingentityDetailComponent, BillingentityMembersComponent], + declarations: [ + BillingEntityComponent, + BillingentityDetailComponent, + BillingentityMembersComponent, + BillingentityViewComponent, + ], imports: [SharedModule, BillingEntityRoutingModule, PanelModule], }) export default class BillingEntityModule {} diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html index 8770e9a8..6972547d 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html @@ -1,60 +1,9 @@ -
-
-
{{ billingEntity.metadata.name }}
- - - -
-
-
    -
  • -
    Display Name
    -
    {{ billingEntity.spec.name }}
    -
  • -
  • -
    Company Email
    -
    -
      -
    • {{ email }}
    • -
    -
    -
  • -
  • -
    Phone
    -
    {{ billingEntity.spec.phone }}
    -
  • -
  • -
    Address
    -
    -
    {{ billingEntity.spec.address.line1 }}
    -
    {{ billingEntity.spec.address.line2 }}
    -
    {{ billingEntity.spec.address.postalCode }} {{ billingEntity.spec.address.city }}
    -
    {{ billingEntity.spec.address.country }}
    -
    -
  • -
  • -
    Invoice Recipient
    -
    - {{ billingEntity.spec.accountingContact.name }} -
      -
    • {{ email }}
    • -
    -
    -
  • -
  • -
    Language Preference
    -
    - {{ billingEntity.spec.languagePreference }} -
    -
  • -
-
-
+
diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts index 07cbfc00..4c85c9a1 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BillingEntity } from '../../types/billing-entity'; import { Observable } from 'rxjs'; -import { faClose, faWarning } from '@fortawesome/free-solid-svg-icons'; +import { faWarning } from '@fortawesome/free-solid-svg-icons'; import { BillingEntityCollectionService } from '../../store/billingentity-collection.service'; @Component({ @@ -15,7 +15,6 @@ export class BillingentityDetailComponent implements OnInit { billingEntity$?: Observable; billingEntityName = ''; - faClose = faClose; faWarning = faWarning; constructor(private route: ActivatedRoute, private billingService: BillingEntityCollectionService) {} diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.html b/src/app/billingentity/billingentity-view/billingentity-view.component.html new file mode 100644 index 00000000..30079da2 --- /dev/null +++ b/src/app/billingentity/billingentity-view/billingentity-view.component.html @@ -0,0 +1,54 @@ +
+
+
{{ billingEntity.metadata.name }}
+ + + +
+
+
    +
  • +
    Display Name
    +
    {{ billingEntity.spec.name }}
    +
  • +
  • +
    Company Email
    +
    +
      +
    • {{ email }}
    • +
    +
    +
  • +
  • +
    Phone
    +
    {{ billingEntity.spec.phone }}
    +
  • +
  • +
    Address
    +
    +
    {{ billingEntity.spec.address.line1 }}
    +
    {{ billingEntity.spec.address.line2 }}
    +
    {{ billingEntity.spec.address.postalCode }} {{ billingEntity.spec.address.city }}
    +
    {{ billingEntity.spec.address.country }}
    +
    +
  • +
  • +
    Invoice Recipient
    +
    + {{ billingEntity.spec.accountingContact.name }} +
      +
    • {{ email }}
    • +
    +
    +
  • +
  • +
    Language Preference
    +
    + {{ billingEntity.spec.languagePreference }} +
    +
  • +
+
+
diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.scss b/src/app/billingentity/billingentity-view/billingentity-view.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.ts b/src/app/billingentity/billingentity-view/billingentity-view.component.ts new file mode 100644 index 00000000..904c8010 --- /dev/null +++ b/src/app/billingentity/billingentity-view/billingentity-view.component.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { faClose } from '@fortawesome/free-solid-svg-icons'; +import { BillingEntity } from '../../types/billing-entity'; + +@Component({ + selector: 'app-billingentity-view', + templateUrl: './billingentity-view.component.html', + styleUrls: ['./billingentity-view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BillingentityViewComponent { + @Input() + billingEntity!: BillingEntity; + + faClose = faClose; +} From ca4accc849aec70ae0c2c4fcb887b4fdb9d52a58 Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 30 Mar 2023 14:34:28 +0200 Subject: [PATCH 03/16] Prevent same-path URL from navigation service If only the query parameters changed, but the path stays the same, don't add to navigation history --- src/app/shared/back-link.directive.ts | 26 +++++++++++++++++++++++--- src/app/shared/navigation.service.ts | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/app/shared/back-link.directive.ts b/src/app/shared/back-link.directive.ts index b85afe17..6bea5561 100644 --- a/src/app/shared/back-link.directive.ts +++ b/src/app/shared/back-link.directive.ts @@ -1,4 +1,4 @@ -import { Directive, HostListener, Input } from '@angular/core'; +import { Directive, HostListener, Input, Optional } from '@angular/core'; import { NavigationService } from './navigation.service'; import { ActivatedRoute, Router } from '@angular/router'; @@ -15,9 +15,29 @@ export class BackLinkDirective { @Input() appBackLink?: string; + @Input() + @Optional() + clearAllQueryParams?: boolean; + + @Input() + @Optional() + removeQueryParamList?: string[]; + @HostListener('click') onClick(): void { - const route = this.navigation.previousLocation(this.appBackLink); - void this.router.navigate([route], { relativeTo: this.activatedRoute }); + let route = this.navigation.previousRoute(this.appBackLink); + while (route.path === window.location.pathname) { + // same path, try again + route = this.navigation.previousRoute(this.appBackLink); + } + if (this.clearAllQueryParams) { + route.queryParams = undefined; + } + this.removeQueryParamList?.forEach((p) => { + if (route.queryParams) { + delete route.queryParams[p]; + } + }); + void this.router.navigate([route.path], { relativeTo: this.activatedRoute, queryParams: route.queryParams }); } } diff --git a/src/app/shared/navigation.service.ts b/src/app/shared/navigation.service.ts index f3230244..fd0f6671 100644 --- a/src/app/shared/navigation.service.ts +++ b/src/app/shared/navigation.service.ts @@ -32,4 +32,28 @@ export class NavigationService { return defaultPath ?? '/'; } } + + /** + * Gets the previous URI location in the history split into path and query params. + * @param defaultPath if the history is empty, return this path as fallback value + * @returns the URI, or '/' if no default was given. If there are no query parameters, `queryParams` will be undefined. + */ + previousRoute(defaultPath?: string): { raw: string; path: string; queryParams?: { [key: string]: string } } { + const raw = this.previousLocation(defaultPath); + let path = raw; + const split = raw.split('?'); + let queryParams: { [key: string]: string } | undefined = undefined; + if (split.length > 1) { + path = split[0]; + queryParams = {}; + new URLSearchParams(`?${split[1]}`).forEach((v, k) => { + queryParams = { ...queryParams, [k]: v }; + }); + } + return { + raw, + path, + queryParams, + }; + } } From 439ef6f9c92e0c0bbc488821dc8400ae54b5a4e0 Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 6 Apr 2023 09:27:18 +0200 Subject: [PATCH 04/16] Get country list from App ConfigMap --- deployment/helmfile.yaml | 11 +++++++++- src/app/app-config.service.ts | 1 + src/config.json | 20 ++++++++++++++++++- .../environment.e2e-development.ts | 6 ++++++ src/environments/environment.ts | 6 ++++++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/deployment/helmfile.yaml b/deployment/helmfile.yaml index cb9811e7..3c9b1574 100644 --- a/deployment/helmfile.yaml +++ b/deployment/helmfile.yaml @@ -14,7 +14,7 @@ helmDefaults: releases: - name: "{{ env "HELM_RELEASE_NAME" | default "portal" }}" chart: appuio/cloud-portal - version: v0.2.0 + version: v0.4.1 createNamespace: false missingFileHandler: Warn values: @@ -56,3 +56,12 @@ releases: kubernetesAPI: Kubernetes API oauth: API Token consoleUrlKey: 'console' + countries: + - code: CH + name: Switzerland + - code: DE + name: Germany + - code: AT + name: Austria + - code: LI + name: Liechtenstein diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 229f96d8..2a7cc80e 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -15,6 +15,7 @@ export interface AppConfig { zoneURLLabels: { [key: string]: string }; consoleUrlKey: string; }; + countries: { code?: string; name: string }[]; } export interface ZoneConfig { diff --git a/src/config.json b/src/config.json index b16a4663..4b220b5b 100644 --- a/src/config.json +++ b/src/config.json @@ -33,5 +33,23 @@ "oauth": "API Token" }, "consoleUrlKey": "console" - } + }, + "countries": [ + { + "code": "CH", + "name": "Switzerland" + }, + { + "code": "DE", + "name": "Germany" + }, + { + "code": "AT", + "name": "Austria" + }, + { + "code": "LI", + "name": "Liechtenstein" + } + ] } diff --git a/src/environments/environment.e2e-development.ts b/src/environments/environment.e2e-development.ts index 73f99ddc..f51f2abd 100644 --- a/src/environments/environment.e2e-development.ts +++ b/src/environments/environment.e2e-development.ts @@ -10,5 +10,11 @@ export const environment: EnvironmentType = { server: '', glitchTipDsn: '', zones: defaultZonesConfig, + countries: [ + { code: 'CH', name: 'Switzerland' }, + { code: 'DE', name: 'Germany' }, + { code: 'AT', name: 'Austria' }, + { code: 'LI', name: 'Liechtenstein' }, + ], }, }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 28ac451a..6f70c7e2 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -16,6 +16,12 @@ export const environment: EnvironmentType = { server: '', glitchTipDsn: '', zones: defaultZonesConfig, + countries: [ + { code: 'CH', name: 'Switzerland' }, + { code: 'DE', name: 'Germany' }, + { code: 'AT', name: 'Austria' }, + { code: 'LI', name: 'Liechtenstein' }, + ], }, }; From 5da29a6cb7f46fb950a0dc55354a6cd0d0f76cb7 Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 5 Apr 2023 15:30:00 +0200 Subject: [PATCH 05/16] Add terminal printer for Cypress tests This should log the console errors and test results to stdout, for easier troubleshooting. --- cypress.config.ts | 7 ++ cypress/support/e2e.ts | 5 ++ package-lock.json | 189 +++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 202 insertions(+) diff --git a/cypress.config.ts b/cypress.config.ts index cdfd77d2..f27323c1 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'cypress'; +import * as installLogsPrinter from 'cypress-terminal-report/src/installLogsPrinter'; export default defineConfig({ videosFolder: 'cypress/videos', @@ -7,5 +8,11 @@ export default defineConfig({ retries: 2, e2e: { baseUrl: 'http://localhost:4200', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setupNodeEvents(on, config) { + installLogsPrinter(on, { + printLogsToConsole: 'always', + }); + }, }, }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 1221b17e..3cac5419 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1 +1,6 @@ import './commands'; +import * as installLogsCollector from 'cypress-terminal-report/src/installLogsCollector'; + +installLogsCollector({ + collectTypes: ['cons:log', 'cons:info', 'cons:warn', 'cons:error', 'cy:log', 'cy:command'], +}); diff --git a/package-lock.json b/package-lock.json index 99fc0ea2..c1dbc41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@typescript-eslint/parser": "5.54.0", "angular-http-server": "1.11.1", "cypress": "12.7.0", + "cypress-terminal-report": "5.1.1", "eslint": "8.35.0", "eslint-plugin-change-detection-strategy": "0.1.1", "eslint-plugin-ngrx": "2.1.4", @@ -8440,6 +8441,105 @@ "node": "^14.0.0 || ^16.0.0 || >=18.0.0" } }, + "node_modules/cypress-terminal-report": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cypress-terminal-report/-/cypress-terminal-report-5.1.1.tgz", + "integrity": "sha512-iZxb6QHV/zf4WRIsaNSf4kXLMgU/qWVLErEIs9kWlpR1mFxoLmSyR2SeC2imFupbvuP5FmkqBT2KVrmTDeQsaA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "fs-extra": "^10.1.0", + "semver": "^7.3.5", + "tv4": "^1.3.0" + }, + "peerDependencies": { + "cypress": ">=4.10.0" + } + }, + "node_modules/cypress-terminal-report/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cypress-terminal-report/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cypress-terminal-report/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cypress-terminal-report/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cypress-terminal-report/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cypress-terminal-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cypress-terminal-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cypress/node_modules/@types/node": { "version": "14.18.36", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.36.tgz", @@ -20872,6 +20972,15 @@ "node": "*" } }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -27960,6 +28069,80 @@ } } }, + "cypress-terminal-report": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cypress-terminal-report/-/cypress-terminal-report-5.1.1.tgz", + "integrity": "sha512-iZxb6QHV/zf4WRIsaNSf4kXLMgU/qWVLErEIs9kWlpR1mFxoLmSyR2SeC2imFupbvuP5FmkqBT2KVrmTDeQsaA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "fs-extra": "^10.1.0", + "semver": "^7.3.5", + "tv4": "^1.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -37320,6 +37503,12 @@ "safe-buffer": "^5.0.1" } }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "dev": true + }, "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 6e96ba74..91a2b0fd 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@typescript-eslint/parser": "5.54.0", "angular-http-server": "1.11.1", "cypress": "12.7.0", + "cypress-terminal-report": "5.1.1", "eslint": "8.35.0", "eslint-plugin-change-detection-strategy": "0.1.1", "eslint-plugin-ngrx": "2.1.4", From b576fecc8a6aa0f1d928e94cb390dd670b9b01cf Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 6 Apr 2023 09:28:08 +0200 Subject: [PATCH 06/16] Increase Angular bundle size thresholds --- angular.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/angular.json b/angular.json index 738b9af4..f6a2391d 100644 --- a/angular.json +++ b/angular.json @@ -67,8 +67,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1500kb", - "maximumError": "2mb" + "maximumWarning": "2mb", + "maximumError": "3mb" }, { "type": "anyComponentStyle", diff --git a/package.json b/package.json index 91a2b0fd..a7ea34f9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "ng": "ng", "start": "ng serve", - "http-server": "angular-http-server --path dist/cloud-portal/en-CH -p 4200", + "http-server": "angular-http-server --silent --path dist/cloud-portal/en-CH -p 4200", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "jest --passWithNoTests", From f49c94ca92acdca89ce37ec3621e7958ddc1de38 Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 5 Apr 2023 11:12:51 +0200 Subject: [PATCH 07/16] Initial BE creation form draft --- .../billing-entity.component.html | 52 +++++-- .../billingentity/billing-entity.component.ts | 76 +++++++--- .../billingentity/billing-entity.module.ts | 5 +- .../billingentity-detail.component.html | 40 ++++- .../billingentity-detail.component.ts | 45 ++++-- .../billingentity-form.component.html | 81 ++++++++++ .../billingentity-form.component.scss | 0 .../billingentity-form.component.ts | 141 ++++++++++++++++++ .../billingentity-form.types.ts | 14 ++ .../billingentity-view.component.html | 98 ++++++------ src/app/shared/shared.module.ts | 2 + .../store/billingentity-collection.service.ts | 30 ++++ src/app/types/entity.ts | 1 + 13 files changed, 485 insertions(+), 100 deletions(-) create mode 100644 src/app/billingentity/billingentity-form/billingentity-form.component.html create mode 100644 src/app/billingentity/billingentity-form/billingentity-form.component.scss create mode 100644 src/app/billingentity/billingentity-form/billingentity-form.component.ts create mode 100644 src/app/billingentity/billingentity-form/billingentity-form.types.ts diff --git a/src/app/billingentity/billing-entity.component.html b/src/app/billingentity/billing-entity.component.html index 0744bff7..bbee7dd0 100644 --- a/src/app/billingentity/billing-entity.component.html +++ b/src/app/billingentity/billing-entity.component.html @@ -1,25 +1,49 @@ -
-

Billing

-
- - - + + +
+

Billing

+ +
+ +
+ +
+ +
{{ p.billingEntity.metadata.name }}
@@ -43,10 +67,10 @@

Billing

- + - +
No billing entities available.
@@ -57,7 +81,7 @@

Billing

- +
Billing entities could not be loaded.
diff --git a/src/app/billingentity/billing-entity.component.ts b/src/app/billingentity/billing-entity.component.ts index ddc25446..85591c07 100644 --- a/src/app/billingentity/billing-entity.component.ts +++ b/src/app/billingentity/billing-entity.component.ts @@ -1,14 +1,10 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { BillingEntity } from '../types/billing-entity'; -import { faEdit, faInfo, faUserGroup, faWarning } from '@fortawesome/free-solid-svg-icons'; -import { map, Observable } from 'rxjs'; +import { faAdd, faEdit, faInfo, faMagnifyingGlass, faUserGroup, faWarning } from '@fortawesome/free-solid-svg-icons'; +import { combineLatestAll, forkJoin, from, map, Observable, of, take } from 'rxjs'; import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; - -interface Payload { - billingEntity: BillingEntity; - canViewMembers$: Observable; -} +import { switchMap } from 'rxjs/operators'; @Component({ selector: 'app-billing-entity', @@ -21,19 +17,65 @@ export class BillingEntityComponent implements OnInit { faInfo = faInfo; faEdit = faEdit; faUserGroup = faUserGroup; - payload$?: Observable; + faDetails = faMagnifyingGlass; + + payload$?: Observable; constructor(public billingEntityService: BillingEntityCollectionService) {} + ngOnInit(): void { - this.payload$ = this.billingEntityService.getAllMemoized().pipe( - map((entities) => - entities.map((be) => { - return { - billingEntity: be, - canViewMembers$: this.billingEntityService.canEditMembers(`billingentities-${be.metadata.name}-admin`), - } satisfies Payload; - }) - ) + this.payload$ = forkJoin([ + this.billingEntityService.getAllMemoized().pipe(take(1)), + this.billingEntityService.canCreateBilling(), + ]).pipe( + switchMap(([entities, canCreateBilling]) => { + if (entities.length === 0) { + // no billing entities + return of({ + canCreateBilling: canCreateBilling, + billingModels: [], + } satisfies ViewModel); + } + + const beModels$: Observable[] = entities.map((be) => { + return forkJoin([ + of(be), + // enrich the BE with information about permissions. + this.billingEntityService.canEditMembers(`billingentities-${be.metadata.name}-admin`), + ]).pipe( + map(([billingEntity, canViewMembers]) => { + return { + billingEntity, + canViewMembers, + } satisfies BillingModel; + }) + ); + }); + return forkJoin([ + of(canCreateBilling), + // Collect all Billing models + from(beModels$).pipe(combineLatestAll()), + ]).pipe( + map(([canCreateBilling, billingModels]) => { + return { + billingModels, + canCreateBilling, + } satisfies ViewModel; + }) + ); + }) ); } + + protected readonly faAdd = faAdd; +} + +interface ViewModel { + billingModels: BillingModel[]; + canCreateBilling: boolean; +} + +interface BillingModel { + billingEntity: BillingEntity; + canViewMembers: boolean; } diff --git a/src/app/billingentity/billing-entity.module.ts b/src/app/billingentity/billing-entity.module.ts index be9cbc0f..1fe359b6 100644 --- a/src/app/billingentity/billing-entity.module.ts +++ b/src/app/billingentity/billing-entity.module.ts @@ -3,9 +3,9 @@ import { SharedModule } from '../shared/shared.module'; import { BillingEntityComponent } from './billing-entity.component'; import { BillingEntityRoutingModule } from './billing-entity-routing.module'; import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; -import { PanelModule } from 'primeng/panel'; import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; import { BillingentityViewComponent } from './billingentity-view/billingentity-view.component'; +import { BillingentityFormComponent } from './billingentity-form/billingentity-form.component'; @NgModule({ declarations: [ @@ -13,7 +13,8 @@ import { BillingentityViewComponent } from './billingentity-view/billingentity-v BillingentityDetailComponent, BillingentityMembersComponent, BillingentityViewComponent, + BillingentityFormComponent, ], - imports: [SharedModule, BillingEntityRoutingModule, PanelModule], + imports: [SharedModule, BillingEntityRoutingModule], }) export default class BillingEntityModule {} diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html index 6972547d..51ae2dd2 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html @@ -1,9 +1,39 @@ - + - - + +
+
+
+ {{ vm.billingEntity.metadata.name }} + + New Billing + +
+ +
+ + +
diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts index 4c85c9a1..f260bac5 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BillingEntity } from '../../types/billing-entity'; -import { Observable } from 'rxjs'; -import { faWarning } from '@fortawesome/free-solid-svg-icons'; +import { forkJoin, map, Observable, of, switchMap } from 'rxjs'; +import { faCancel, faClose, faEdit, faWarning } from '@fortawesome/free-solid-svg-icons'; import { BillingEntityCollectionService } from '../../store/billingentity-collection.service'; @Component({ @@ -12,19 +12,46 @@ import { BillingEntityCollectionService } from '../../store/billingentity-collec changeDetection: ChangeDetectionStrategy.OnPush, }) export class BillingentityDetailComponent implements OnInit { - billingEntity$?: Observable; + viewModel$?: Observable; + isEditing$?: Observable; billingEntityName = ''; faWarning = faWarning; + faEdit = faEdit; + faCancel = faCancel; + faClose = faClose; constructor(private route: ActivatedRoute, private billingService: BillingEntityCollectionService) {} ngOnInit(): void { - const name = this.route.snapshot.paramMap.get('name'); - if (!name) { - throw new Error('name is required'); - } - this.billingEntityName = name; - this.billingEntity$ = this.billingService.getByKeyMemoized(this.billingEntityName); + this.isEditing$ = this.route.queryParamMap.pipe(map((queryParams) => queryParams.get('edit') === 'y')); + this.viewModel$ = this.route.paramMap.pipe( + switchMap((params) => { + const name = params.get('name'); + if (!name) { + throw new Error('name is required'); + } + this.billingEntityName = name; + if (name === '$new') { + return forkJoin([of(this.billingService.newBillingEntity()), of(true)]); + } + return forkJoin([this.billingService.getByKeyMemoized(name), this.billingService.canEditBilling(name)]); + }), + map(([billingEntity, canEdit]) => { + return { + billingEntity, + canEdit, + } satisfies ViewModel; + }) + ); } + + isNewBE(be: BillingEntity): boolean { + return !!be.metadata.generateName; + } +} + +interface ViewModel { + billingEntity: BillingEntity; + canEdit: boolean; } diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billingentity-form.component.html new file mode 100644 index 00000000..0ae360b2 --- /dev/null +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.html @@ -0,0 +1,81 @@ +
+
+ + +
+ + Company +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + +
+ + + Is your country missing? + + Please let us know! + + +
+ +
+ + Invoice Recipient +
+
+ + +
+
+ + +
+
+ +
+ + +
+
diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.scss b/src/app/billingentity/billingentity-form/billingentity-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billingentity-form.component.ts new file mode 100644 index 00000000..34b02202 --- /dev/null +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.ts @@ -0,0 +1,141 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { faCancel, faSave } from '@fortawesome/free-solid-svg-icons'; +import { BillingEntityCollectionService } from '../../store/billingentity-collection.service'; +import { BillingEntity } from '../../types/billing-entity'; +import { BillingForm } from './billingentity-form.types'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AppConfigService } from '../../app-config.service'; +import { MessageService } from 'primeng/api'; +import { NavigationService } from '../../shared/navigation.service'; + +@Component({ + selector: 'app-billingentity-form', + templateUrl: './billingentity-form.component.html', + styleUrls: ['./billingentity-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BillingentityFormComponent implements OnInit { + @Input() + billingEntity!: BillingEntity; + + form!: FormGroup; + + faSave = faSave; + faCancel = faCancel; + countryOptions: { code?: string; name: string }[] = []; + + constructor( + public billingService: BillingEntityCollectionService, + private formBuilder: FormBuilder, + private router: Router, + private activatedRoute: ActivatedRoute, + private navigationService: NavigationService, + private messageService: MessageService, + appConfig: AppConfigService + ) { + this.countryOptions = appConfig.getConfiguration().countries; + } + + ngOnInit(): void { + const spec = this.billingEntity.spec; + this.form = this.formBuilder.nonNullable.group({ + companyEmail: new FormControl(spec.emails ?? [], { + nonNullable: true, + validators: [Validators.email, Validators.required], + }), + displayName: new FormControl(spec.name ?? '', { + nonNullable: true, + validators: [Validators.required], + }), + phone: new FormControl(spec.phone ?? '', { nonNullable: true, validators: [Validators.required] }), + line1: new FormControl(spec.address?.line1 ?? '', { nonNullable: true, validators: [Validators.required] }), + line2: new FormControl(spec.address?.line2 ?? '', { nonNullable: true }), + postal: new FormControl(spec.address?.postalCode ?? '', { nonNullable: true, validators: [Validators.required] }), + city: new FormControl(spec.address?.city ?? '', { nonNullable: true, validators: [Validators.required] }), + country: new FormControl<{ name: string } | undefined>( + this.countryOptions.find((o) => o.name === spec.address?.country), + { + nonNullable: true, + validators: [Validators.required], + } + ), + accountingName: new FormControl(spec.accountingContact?.name ?? '', { + nonNullable: true, + validators: [Validators.required], + }), + accountingEmail: new FormControl(spec.accountingContact?.emails ?? [], { + nonNullable: true, + validators: [Validators.email, Validators.required], + }), + } satisfies BillingForm); + } + + save(): void { + if (this.form.invalid) { + return; + } + const controls = this.form.controls; + const be = structuredClone(this.billingEntity); + be.spec = { + name: controls.displayName.value, + phone: controls.phone.value, + address: { + line1: controls.line1.value, + line2: controls.line2.value, + postalCode: controls.postal.value, + city: controls.city.value, + country: controls.country.value?.name, + }, + emails: controls.companyEmail.value, + }; + if (this.isNewBe(be)) { + this.billingService.add(be).subscribe({ + next: (result) => this.saveOrUpdateSuccess(result), + error: (err) => this.saveOrUpdateFailure(err), + }); + } else { + this.billingService.update(be).subscribe({ + next: (result) => this.saveOrUpdateSuccess(result), + error: (err) => this.saveOrUpdateFailure(err), + }); + } + } + + cancel(): void { + void this.router.navigate([this.navigationService.previousLocation('..')], { + queryParams: { edit: undefined }, + queryParamsHandling: 'merge', + relativeTo: this.activatedRoute, + }); + } + + private isNewBe(be?: BillingEntity): boolean { + return be ? !!be.metadata.generateName : !!this.billingEntity.metadata.generateName; + } + + private saveOrUpdateSuccess(be: BillingEntity): void { + this.messageService.add({ + severity: 'success', + summary: $localize`Successfully saved`, + }); + void this.router.navigate(['..', be.metadata.name], { + relativeTo: this.activatedRoute, + queryParams: { edit: undefined }, + queryParamsHandling: 'merge', + }); + } + + private saveOrUpdateFailure(err: Error): void { + let detail = ''; + if ('message' in err) { + detail = err.message; + } + this.messageService.add({ + severity: 'error', + summary: $localize`Error`, + sticky: true, + detail, + }); + } +} diff --git a/src/app/billingentity/billingentity-form/billingentity-form.types.ts b/src/app/billingentity/billingentity-form/billingentity-form.types.ts new file mode 100644 index 00000000..ec0ca6e7 --- /dev/null +++ b/src/app/billingentity/billingentity-form/billingentity-form.types.ts @@ -0,0 +1,14 @@ +import { FormControl } from '@angular/forms'; + +export interface BillingForm { + companyEmail: FormControl; + displayName: FormControl; + phone: FormControl; + line1: FormControl; + line2: FormControl; + postal: FormControl; + city: FormControl; + country: FormControl<{ name: string } | undefined>; + accountingName: FormControl; + accountingEmail: FormControl; +} diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.html b/src/app/billingentity/billingentity-view/billingentity-view.component.html index 30079da2..37eae734 100644 --- a/src/app/billingentity/billingentity-view/billingentity-view.component.html +++ b/src/app/billingentity/billingentity-view/billingentity-view.component.html @@ -1,54 +1,46 @@ -
-
-
{{ billingEntity.metadata.name }}
- - - -
-
-
    -
  • -
    Display Name
    -
    {{ billingEntity.spec.name }}
    -
  • -
  • -
    Company Email
    -
    -
      -
    • {{ email }}
    • -
    -
    -
  • -
  • -
    Phone
    -
    {{ billingEntity.spec.phone }}
    -
  • -
  • -
    Address
    -
    -
    {{ billingEntity.spec.address.line1 }}
    -
    {{ billingEntity.spec.address.line2 }}
    -
    {{ billingEntity.spec.address.postalCode }} {{ billingEntity.spec.address.city }}
    -
    {{ billingEntity.spec.address.country }}
    -
    -
  • -
  • -
    Invoice Recipient
    -
    - {{ billingEntity.spec.accountingContact.name }} -
      -
    • {{ email }}
    • -
    -
    -
  • -
  • -
    Language Preference
    -
    - {{ billingEntity.spec.languagePreference }} -
    -
  • -
-
+
+
    +
  • +
    Display Name
    +
    {{ billingEntity.spec.name }}
    +
  • +
  • +
    Company Email
    +
    +
      +
    • {{ email }}
    • +
    +
    +
  • +
  • +
    Phone
    +
    {{ billingEntity.spec.phone }}
    +
  • +
  • +
    Address
    +
    +
    {{ billingEntity.spec.address.line1 }}
    +
    {{ billingEntity.spec.address.line2 }}
    +
    {{ billingEntity.spec.address.postalCode }} {{ billingEntity.spec.address.city }}
    +
    {{ billingEntity.spec.address.country }}
    +
    +
  • +
  • +
    Invoice Recipient
    +
    + {{ billingEntity.spec.accountingContact.name }} +
      +
    • {{ email }}
    • +
    +
    +
  • +
  • +
    Language Preference
    +
    + {{ billingEntity.spec.languagePreference }} +
    +
  • +
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 736c174c..67976dbe 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { BackLinkDirective } from './back-link.directive'; import { TableModule } from 'primeng/table'; import { InputTextareaModule } from 'primeng/inputtextarea'; +import { ChipsModule } from 'primeng/chips'; @NgModule({ declarations: [BackLinkDirective], @@ -53,6 +54,7 @@ import { InputTextareaModule } from 'primeng/inputtextarea'; BackLinkDirective, TableModule, InputTextareaModule, + ChipsModule, ], }) export class SharedModule {} diff --git a/src/app/store/billingentity-collection.service.ts b/src/app/store/billingentity-collection.service.ts index 158cdcc6..0485268a 100644 --- a/src/app/store/billingentity-collection.service.ts +++ b/src/app/store/billingentity-collection.service.ts @@ -37,6 +37,16 @@ export class BillingEntityCollectionService extends KubernetesCollectionService< ); } + canEditBilling(name: string): Observable { + return this.permissionService.isAllowed( + BillingEntityPermissions.group, + BillingEntityPermissions.resource, + Verb.Update, + undefined, + name + ); + } + canEditMembers(clusterRoleBindingName: string): Observable { return this.permissionService.isAllowed( ClusterRoleBindingPermissions.group, @@ -46,4 +56,24 @@ export class BillingEntityCollectionService extends KubernetesCollectionService< clusterRoleBindingName ); } + + canCreateBilling(): Observable { + return this.permissionService.isAllowed( + BillingEntityPermissions.group, + BillingEntityPermissions.resource, + Verb.Create + ); + } + + newBillingEntity(): BillingEntity { + return { + apiVersion: 'billing.appuio.io/v1', + kind: 'BillingEntity', + metadata: { + name: '', + generateName: 'be-', + }, + spec: {}, + }; + } } diff --git a/src/app/types/entity.ts b/src/app/types/entity.ts index 187e97c0..e9772267 100644 --- a/src/app/types/entity.ts +++ b/src/app/types/entity.ts @@ -7,6 +7,7 @@ export interface KubeObject { labels?: { [key: string]: string }; uid?: string; resourceVersion?: string; + generateName?: string; [key: string]: unknown; }; kind: string; From 79c0fdaeccbb3264c049b32e12a4d9eb4b8758fc Mon Sep 17 00:00:00 2001 From: ccremer Date: Mon, 3 Apr 2023 15:41:01 +0200 Subject: [PATCH 08/16] Add validator for multiple emails --- .../billingentity-form.component.ts | 5 +-- .../billingentity-form.util.ts | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/app/billingentity/billingentity-form/billingentity-form.util.ts diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billingentity-form.component.ts index 34b02202..e74c4fd6 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.ts @@ -8,6 +8,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AppConfigService } from '../../app-config.service'; import { MessageService } from 'primeng/api'; import { NavigationService } from '../../shared/navigation.service'; +import { multiEmail } from './billingentity-form.util'; @Component({ selector: 'app-billingentity-form', @@ -42,7 +43,7 @@ export class BillingentityFormComponent implements OnInit { this.form = this.formBuilder.nonNullable.group({ companyEmail: new FormControl(spec.emails ?? [], { nonNullable: true, - validators: [Validators.email, Validators.required], + validators: [multiEmail, Validators.required], }), displayName: new FormControl(spec.name ?? '', { nonNullable: true, @@ -66,7 +67,7 @@ export class BillingentityFormComponent implements OnInit { }), accountingEmail: new FormControl(spec.accountingContact?.emails ?? [], { nonNullable: true, - validators: [Validators.email, Validators.required], + validators: [multiEmail, Validators.required], }), } satisfies BillingForm); } diff --git a/src/app/billingentity/billingentity-form/billingentity-form.util.ts b/src/app/billingentity/billingentity-form/billingentity-form.util.ts new file mode 100644 index 00000000..e0b708b7 --- /dev/null +++ b/src/app/billingentity/billingentity-form/billingentity-form.util.ts @@ -0,0 +1,32 @@ +import { AbstractControl, FormControl, ValidationErrors, Validators } from '@angular/forms'; + +export const asdf = /^$/; + +// Copied from https://github.com/angular/angular/blob/b3d2e3312ae682aeb96bc783cc44e9ee4575fda4/packages/forms/src/validators.ts#L18 +function isEmptyInputValue(value: any): boolean { + /** + * Check if the object is a string or array before evaluating the length attribute. + * This avoids falsely rejecting objects that contain a custom length attribute. + * For example, the object {id: 1, length: 0, width: 0} should not be returned as empty. + */ + return value == null || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0); +} + +export function multiEmail(control: AbstractControl): ValidationErrors | null { + if (isEmptyInputValue(control.value)) { + return null; // don't validate empty values to allow optional controls + } + if (!Array.isArray(control.value)) { + return Validators.email(control); + } + const arr = control.value as string[]; + let allErrors: ValidationErrors | null = null; + arr.forEach((v) => { + const newControl = new FormControl(v); + const err = Validators.email(newControl); + if (err !== null) { + allErrors = err; + } + }); + return allErrors; +} From 9843b9c770e56889e91179cdd4c3ef31905b2c2b Mon Sep 17 00:00:00 2001 From: ccremer Date: Mon, 3 Apr 2023 14:16:14 +0200 Subject: [PATCH 09/16] Add divider to BE form --- src/app/app.component.html | 2 +- .../billingentity-form.component.html | 18 +++++++++++++----- .../billingentity-form.component.ts | 4 ++++ src/app/shared/shared.module.ts | 2 ++ src/styles.scss | 12 ++++++++++++ 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 499be484..5bd44902 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,6 @@
diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billingentity-form.component.html index 0ae360b2..e3fe8a0a 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.html +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.html @@ -1,11 +1,15 @@
-
+
- Company -
+ +
+ Company +
+
+
@@ -52,8 +56,12 @@
- Invoice Recipient -
+ +
+ Invoice Recipient +
+
+
Date: Thu, 6 Apr 2023 14:18:57 +0200 Subject: [PATCH 10/16] Add checkbox to copy company email --- .../billingentity-detail.component.html | 4 +- .../billingentity-form.component.html | 43 ++++++++---- .../billingentity-form.component.ts | 68 +++++++++++++------ .../billingentity-form.types.ts | 1 + .../billingentity-form.util.ts | 15 ++-- .../invitation-form.component.scss | 4 -- src/styles.scss | 5 ++ 7 files changed, 98 insertions(+), 42 deletions(-) diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html index 51ae2dd2..36334211 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html +++ b/src/app/billingentity/billingentity-detail/billingentity-detail.component.html @@ -3,10 +3,10 @@
-
+
{{ vm.billingEntity.metadata.name }} - New Billing + New Billing
diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billingentity-form.component.html index e3fe8a0a..d8767dba 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.html +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.html @@ -1,10 +1,10 @@
- +
- +
Company
@@ -12,9 +12,15 @@
- - + +
@@ -36,7 +42,7 @@
Is your country missing? - + Please let us know! @@ -56,7 +62,7 @@
- +
Invoice Recipient
@@ -68,9 +74,22 @@ i18n-placeholder/>
- - + +
diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billingentity-form.component.ts index d14614fe..22ca13a9 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.ts @@ -8,7 +8,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AppConfigService } from '../../app-config.service'; import { MessageService } from 'primeng/api'; import { NavigationService } from '../../shared/navigation.service'; -import { multiEmail } from './billingentity-form.util'; +import { multiEmail, sameEntries } from './billingentity-form.util'; +import { filter } from 'rxjs'; @Component({ selector: 'app-billingentity-form', @@ -24,7 +25,7 @@ export class BillingentityFormComponent implements OnInit { faSave = faSave; faCancel = faCancel; - countryOptions: { code?: string; name: string }[] = []; + countryOptions?: { code?: string; name: string }[] = []; constructor( public billingService: BillingEntityCollectionService, @@ -33,43 +34,67 @@ export class BillingentityFormComponent implements OnInit { private activatedRoute: ActivatedRoute, private navigationService: NavigationService, private messageService: MessageService, - appConfig: AppConfigService - ) { - this.countryOptions = appConfig.getConfiguration().countries; - } + private appConfig: AppConfigService + ) {} ngOnInit(): void { + this.countryOptions = this.appConfig.getConfiguration().countries; const spec = this.billingEntity.spec; + const companyEmails = spec.emails?.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) ?? []; + const accountingEmails = + spec.accountingContact?.emails?.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) ?? []; + const sameEmails = sameEntries(companyEmails, accountingEmails); + const preselectedCountry = this.countryOptions.find((o) => o.name === spec.address?.country); this.form = this.formBuilder.nonNullable.group({ - companyEmail: new FormControl(spec.emails ?? [], { + displayName: new FormControl(spec.name ?? '', { nonNullable: true, - validators: [multiEmail, Validators.required], + validators: [Validators.required, Validators.minLength(2)], }), - displayName: new FormControl(spec.name ?? '', { + companyEmail: new FormControl(companyEmails, { nonNullable: true, - validators: [Validators.required], + validators: [multiEmail, Validators.required], }), phone: new FormControl(spec.phone ?? '', { nonNullable: true, validators: [Validators.required] }), line1: new FormControl(spec.address?.line1 ?? '', { nonNullable: true, validators: [Validators.required] }), line2: new FormControl(spec.address?.line2 ?? '', { nonNullable: true }), postal: new FormControl(spec.address?.postalCode ?? '', { nonNullable: true, validators: [Validators.required] }), city: new FormControl(spec.address?.city ?? '', { nonNullable: true, validators: [Validators.required] }), - country: new FormControl<{ name: string } | undefined>( - this.countryOptions.find((o) => o.name === spec.address?.country), - { - nonNullable: true, - validators: [Validators.required], - } - ), - accountingName: new FormControl(spec.accountingContact?.name ?? '', { + country: new FormControl<{ name: string } | undefined>(preselectedCountry, { nonNullable: true, validators: [Validators.required], }), - accountingEmail: new FormControl(spec.accountingContact?.emails ?? [], { + accountingName: new FormControl(spec.accountingContact?.name ?? '', { nonNullable: true, - validators: [multiEmail, Validators.required], + validators: [Validators.required], }), + accountingEmail: new FormControl( + { + value: accountingEmails, + disabled: sameEmails, + }, + { + nonNullable: true, + validators: [multiEmail, Validators.required], + } + ), + sameCompanyEmailsSelected: new FormControl(sameEmails, { nonNullable: true }), } satisfies BillingForm); + + this.form.controls.companyEmail.valueChanges + .pipe(filter(() => this.form.controls.sameCompanyEmailsSelected.value)) + .subscribe((emails) => this.form.controls.accountingEmail.setValue(emails)); + + this.form.controls.sameCompanyEmailsSelected.valueChanges.subscribe((isSelected) => { + if (isSelected) { + this.form.controls.accountingEmail.disable(); + this.form.controls.accountingEmail.setValue(this.form.controls.companyEmail.value); + } else { + this.form.controls.accountingEmail.enable(); + this.form.controls.accountingEmail.setValue( + accountingEmails.length > 0 ? accountingEmails : this.form.controls.companyEmail.value + ); + } + }); } save(): void { @@ -79,9 +104,11 @@ export class BillingentityFormComponent implements OnInit { const controls = this.form.controls; const be = structuredClone(this.billingEntity); be.spec = { + ...be.spec, name: controls.displayName.value, phone: controls.phone.value, address: { + ...be.spec.address, line1: controls.line1.value, line2: controls.line2.value, postalCode: controls.postal.value, @@ -90,6 +117,7 @@ export class BillingentityFormComponent implements OnInit { }, emails: controls.companyEmail.value, accountingContact: { + ...be.spec.accountingContact, name: controls.accountingName.value, emails: controls.accountingEmail.value, }, diff --git a/src/app/billingentity/billingentity-form/billingentity-form.types.ts b/src/app/billingentity/billingentity-form/billingentity-form.types.ts index ec0ca6e7..d6e38862 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.types.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.types.ts @@ -11,4 +11,5 @@ export interface BillingForm { country: FormControl<{ name: string } | undefined>; accountingName: FormControl; accountingEmail: FormControl; + sameCompanyEmailsSelected: FormControl; } diff --git a/src/app/billingentity/billingentity-form/billingentity-form.util.ts b/src/app/billingentity/billingentity-form/billingentity-form.util.ts index e0b708b7..8738f7fa 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.util.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.util.ts @@ -1,15 +1,15 @@ import { AbstractControl, FormControl, ValidationErrors, Validators } from '@angular/forms'; -export const asdf = /^$/; - // Copied from https://github.com/angular/angular/blob/b3d2e3312ae682aeb96bc783cc44e9ee4575fda4/packages/forms/src/validators.ts#L18 -function isEmptyInputValue(value: any): boolean { +function isEmptyInputValue(value: unknown): boolean { /** * Check if the object is a string or array before evaluating the length attribute. * This avoids falsely rejecting objects that contain a custom length attribute. * For example, the object {id: 1, length: 0, width: 0} should not be returned as empty. */ - return value == null || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0); + return ( + value === null || value === undefined || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0) + ); } export function multiEmail(control: AbstractControl): ValidationErrors | null { @@ -30,3 +30,10 @@ export function multiEmail(control: AbstractControl): ValidationErrors | null { }); return allErrors; } + +export function sameEntries(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + return a.every((s, index) => b[index] === s); +} diff --git a/src/app/invitations/invitation-form/invitation-form.component.scss b/src/app/invitations/invitation-form/invitation-form.component.scss index e5be7e54..e69de29b 100644 --- a/src/app/invitations/invitation-form/invitation-form.component.scss +++ b/src/app/invitations/invitation-form/invitation-form.component.scss @@ -1,4 +0,0 @@ -:host ::ng-deep .p-checkbox, :host ::ng-deep .p-checkbox-box { - width: 24px; - height: 24px; -} diff --git a/src/styles.scss b/src/styles.scss index 30cde510..93cb5261 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -63,3 +63,8 @@ body { left: 0.5rem !important; border-top: 1px var(--gray-400) solid !important } + +.p-checkbox, .p-checkbox-box { + width: 24px !important; + height: 24px !important; +} From 6bb08f539dc2b0f9a6c5269c024e4179c829f358 Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 5 Apr 2023 10:55:38 +0200 Subject: [PATCH 11/16] Add BE e2e test for Creating --- cypress/e2e/billingentities.cy.ts | 19 ++- cypress/e2e/billingentity-form.cy.ts | 208 +++++++++++++++++++++++++++ cypress/fixtures/billingentities.ts | 6 +- 3 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 cypress/e2e/billingentity-form.cy.ts diff --git a/cypress/e2e/billingentities.cy.ts b/cypress/e2e/billingentities.cy.ts index 0293305d..7da89e10 100644 --- a/cypress/e2e/billingentities.cy.ts +++ b/cypress/e2e/billingentities.cy.ts @@ -13,13 +13,14 @@ describe('Test billing entity list', () => { cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), }); - cy.setPermission({ verb: 'list', ...BillingEntityPermissions }); + cy.setPermission({ verb: 'list', ...BillingEntityPermissions }, { verb: 'create', ...BillingEntityPermissions }); }); it('list with two entries', () => { setBillingEntities(cy, billingEntityNxt, billingEntityVshn); cy.visit('/billingentities'); cy.get('#billingentities-title').should('contain.text', 'Billing'); + cy.get('#addButton').should('contain.text', 'Add new Billing'); cy.get(':nth-child(2) > .flex-row > .text-3xl').should('contain.text', 'be-2345'); cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'be-2347'); }); @@ -28,6 +29,7 @@ describe('Test billing entity list', () => { setBillingEntities(cy); cy.visit('/billingentities'); cy.get('#billingentities-title').should('contain.text', 'Billing'); + cy.get('#addButton').should('contain.text', 'Add new Billing'); cy.get('#no-billingentity-message').should('contain.text', 'No billing entities available.'); }); @@ -36,7 +38,6 @@ describe('Test billing entity list', () => { statusCode: 403, }); cy.visit('/billingentities'); - cy.get('#billingentities-title').should('contain.text', 'Billing'); cy.get('#failure-message').should('contain.text', 'Billing entities could not be loaded.'); }); @@ -83,6 +84,14 @@ describe('no permissions', () => { cy.visit('/billingentities/be-2345/members'); cy.get('h1').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); }); + + it('no create permission', () => { + setBillingEntities(cy); + cy.setPermission({ verb: 'list', ...BillingEntityPermissions }); + cy.visit('/billingentities'); + cy.get('h1').should('contain.text', 'Billing'); + cy.get('addButton').should('not.exist'); + }); }); describe('Test billing entity details', () => { @@ -113,10 +122,10 @@ describe('Test billing entity details', () => { }); cy.visit('/billingentities/be-2345'); cy.get('.flex-wrap > .text-900').eq(0).should('contain.text', '➑️ Engineering GmbH'); - cy.get('.flex-wrap > .text-900').eq(1).should('contain.text', 'πŸ“§'); + cy.get('.flex-wrap > .text-900').eq(1).should('contain.text', 'hallo@nxt.engineering'); cy.get('.flex-wrap > .text-900').eq(2).should('contain.text', '☎️'); - cy.get('.flex-wrap > .text-900').eq(3).should('contain.text', 'πŸ“ƒπŸ“‹πŸ€ πŸ™οΈπŸ‡¨πŸ‡­'); - cy.get('.flex-wrap > .text-900').eq(4).should('contain.text', 'mig πŸ“§'); + cy.get('.flex-wrap > .text-900').eq(3).should('contain.text', 'πŸ“ƒπŸ“‹πŸ€ πŸ™οΈSwitzerland'); + cy.get('.flex-wrap > .text-900').eq(4).should('contain.text', 'mig hallo@nxt.engineering'); cy.get('.flex-wrap > .text-900').eq(5).should('contain.text', 'πŸ‡©πŸ‡ͺ'); }); }); diff --git a/cypress/e2e/billingentity-form.cy.ts b/cypress/e2e/billingentity-form.cy.ts new file mode 100644 index 00000000..b1d320a0 --- /dev/null +++ b/cypress/e2e/billingentity-form.cy.ts @@ -0,0 +1,208 @@ +import { createUser } from '../fixtures/user'; +import { BillingEntity, BillingEntityPermissions, BillingEntitySpec } from '../../src/app/types/billing-entity'; +import { billingEntityNxt, setBillingEntities } from '../fixtures/billingentities'; + +describe('Test billing entity form elements', () => { + beforeEach(() => { + cy.setupAuth(); + window.localStorage.setItem('hideFirstTimeLoginDialog', 'true'); + cy.disableCookieBanner(); + }); + beforeEach(() => { + // needed for initial getUser request + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { + body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), + }); + cy.setPermission({ verb: 'list', ...BillingEntityPermissions }, { verb: 'create', ...BillingEntityPermissions }); + }); + + it('mark all fields required', () => { + cy.visit('/billingentities/$new?edit=y'); + cy.get('#title').should('contain.text', 'New Billing'); + cy.get('button[type="submit"]').should('be.disabled'); + + cy.get('#displayName') + .type('a') + .should('have.class', 'ng-invalid') + .type('b') + .should('not.have.class', 'ng-invalid'); + + cy.get('#companyEmail').find('input').type('a{enter}'); + cy.get('#companyEmail').should('have.class', 'ng-invalid'); + cy.get('#companyEmail').find('input').type('{backspace}info@company,'); + cy.get('#companyEmail').should('not.have.class', 'ng-invalid'); + + cy.get('#phone') + .type('1{backspace}') + .should('have.class', 'ng-invalid') + .type('1234') + .should('not.have.class', 'ng-invalid'); + + cy.get('#line1') + .type('1{backspace}') + .should('have.class', 'ng-invalid') + .type('line1') + .should('not.have.class', 'ng-invalid'); + + cy.get('#line2').type('2{backspace}').should('not.have.class', 'ng-invalid').type('line2'); // not required + + cy.get('#postal') + .type('p{backspace}') + .should('have.class', 'ng-invalid') + .type('postal') + .should('not.have.class', 'ng-invalid'); + + cy.get('#city') + .type('c{backspace}') + .should('have.class', 'ng-invalid') + .type('city') + .should('not.have.class', 'ng-invalid'); + + cy.get('#accountingName') + .type('a{backspace}') + .should('have.class', 'ng-invalid') + .type('accounting') + .should('not.have.class', 'ng-invalid'); + + cy.get('button[type="submit"]').should('be.disabled'); + cy.get('p-dropdown').click().contains('Switzerland').click(); + cy.get('button[type="submit"]').should('be.enabled'); + }); + + it('should validate emails', () => { + cy.visit('/billingentities/$new?edit=y'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.get('.p-checkbox-box').click(); + + ['#companyEmail', '#accountingEmail'].forEach((e) => { + cy.get(e).find('input').type('a{enter}'); + cy.get(e).should('have.class', 'ng-invalid'); + cy.get(e).find('input').type('{backspace}info@company,'); + cy.get(e).should('not.have.class', 'ng-invalid'); + cy.get(e).find('input').type('b{enter}'); + cy.get(e).should('have.class', 'ng-invalid'); + cy.get(e).find('input').type('{backspace}another@tld,'); + cy.get(e).should('not.have.class', 'ng-invalid'); + }); + }); + + it('should copy emails from company', () => { + cy.visit('/billingentities/$new?edit=y'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.get('#companyEmail').find('input').type('info@company,'); + cy.get('#accountingEmail').find('ul').should('have.class', 'p-disabled').should('contain.text', 'info@company'); + + cy.get('#companyEmail').find('input').type('hello@company{enter}'); + cy.get('#accountingEmail') + .find('ul') + .should('contain.text', 'info@company') + .should('contain.text', 'hello@company'); + + cy.get('.p-checkbox-box').click(); + + cy.get('#companyEmail').find('input').type('{backspace}'); + cy.get('#companyEmail').should('not.contain.text', 'hello@company'); + cy.get('#accountingEmail') + .find('ul') + .should('not.have.class', 'p-disabled') + .should('contain.text', 'info@company') + .should('contain.text', 'hello@company'); + + cy.get('#accountingEmail').find('input').type('{backspace}{backspace}'); + cy.get('#accountingEmail').should('have.class', 'ng-invalid'); + cy.get('#accountingEmail').find('input').type('accounting@company,'); + cy.get('#accountingEmail').should('not.have.class', 'ng-invalid'); + + cy.get('.p-checkbox-box').click(); + + cy.get('#accountingEmail') + .find('ul') + .should('have.class', 'p-disabled') + .should('contain.text', 'info@company') + .should('not.contain.text', 'hello@company') + .should('not.contain.text', 'accounting@company'); + }); + + it('should cancel editing', () => { + setBillingEntities(cy); + + ['.p-button-secondary', 'a[appbacklink]'].forEach((cancelSelector) => { + cy.visit('/billingentities/$new?edit=y'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.get(cancelSelector).click(); + cy.get('app-billingentity-form').should('not.exist'); + + cy.get('p-messages').should('contain.text', 'No billing entities available'); + }); + }); +}); + +describe('Test billing entity create', () => { + beforeEach(() => { + cy.setupAuth(); + window.localStorage.setItem('hideFirstTimeLoginDialog', 'true'); + cy.disableCookieBanner(); + }); + beforeEach(() => { + // needed for initial getUser request + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { + body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), + }); + cy.setPermission({ verb: 'list', ...BillingEntityPermissions }, { verb: 'create', ...BillingEntityPermissions }); + }); + + it('should submit form values', () => { + cy.intercept('POST', '/appuio-api/apis/billing.appuio.io/v1/billingentities', { + body: billingEntityNxt, + }).as('createBillingEntity'); + + cy.visit('/billingentities/$new?edit=y'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.get('#displayName').type('➑️ Engineering GmbH'); + + cy.get('#companyEmail').find('input').type('hallo@nxt.engineering,'); + cy.get('#phone').type('☎️'); + cy.get('#line1').type('πŸ“ƒ'); + cy.get('#line2').type('πŸ“‹'); + cy.get('#postal').type('🏀'); + cy.get('#city').type('πŸ™οΈ'); + cy.get('p-dropdown').click().contains('Switzerland').click(); + + cy.get('#accountingName').type('mig'); + + cy.get('button[type="submit"]').should('be.enabled').click(); + cy.wait('@createBillingEntity') + .its('request.body') + .then((body: BillingEntity) => { + expect(body.metadata.generateName).eq('be-'); + expect(body.metadata.name).empty; // ensure no metadata name is set for new objects + const expected: BillingEntitySpec = { + name: '➑️ Engineering GmbH', + phone: '☎️', + emails: ['hallo@nxt.engineering'], + address: { + line1: 'πŸ“ƒ', + line2: 'πŸ“‹', + postalCode: '🏀', + city: 'πŸ™οΈ', + country: 'Switzerland', + }, + accountingContact: { + name: 'mig', + emails: ['hallo@nxt.engineering'], + }, + }; + console.debug('expected', expected); + console.debug('actual', body.spec); + expect(body.spec).deep.eq(expected); + }); + + cy.get('p-toast').should('contain.text', 'Successfully saved'); + cy.get('#title').should('contain.text', 'be-2345'); + cy.url().should('include', '/billingentities/be-2345'); + }); +}); diff --git a/cypress/fixtures/billingentities.ts b/cypress/fixtures/billingentities.ts index a949da53..c4365059 100644 --- a/cypress/fixtures/billingentities.ts +++ b/cypress/fixtures/billingentities.ts @@ -13,14 +13,14 @@ export const billingEntityNxt: BillingEntity = { line2: 'πŸ“‹', postalCode: '🏀', city: 'πŸ™οΈ', - country: 'πŸ‡¨πŸ‡­', + country: 'Switzerland', }, - emails: ['πŸ“§'], + emails: ['hallo@nxt.engineering'], phone: '☎️', languagePreference: 'πŸ‡©πŸ‡ͺ', accountingContact: { name: 'mig', - emails: ['πŸ“§'], + emails: ['hallo@nxt.engineering'], }, }, }; From 6c7173445187e9632e6af8b3d6cc77afd77822c8 Mon Sep 17 00:00:00 2001 From: ccremer Date: Wed, 12 Apr 2023 08:48:19 +0200 Subject: [PATCH 12/16] Remove placeholder in accouting name --- .../billingentity-form/billingentity-form.component.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billingentity-form.component.html index d8767dba..e9df2d5f 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.html +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.html @@ -70,8 +70,7 @@
- +
+
diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billingentity-form.component.ts index 22ca13a9..5d1c8cb5 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.ts @@ -26,6 +26,7 @@ export class BillingentityFormComponent implements OnInit { faSave = faSave; faCancel = faCancel; countryOptions?: { code?: string; name: string }[] = []; + emailSeparatorExp = /[, ]/; constructor( public billingService: BillingEntityCollectionService, From 8cd6f9e87bf19c02b7aa32349e27717c64fc8497 Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 12:59:44 +0200 Subject: [PATCH 15/16] Add legal note when registering new BE --- .../billingentity-form.component.html | 21 ++++++++++++++++--- .../billingentity-form.component.ts | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billingentity-form.component.html index c1bdf630..f6829b38 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.html +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.html @@ -95,9 +95,24 @@
- diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billingentity-form.component.ts index 5d1c8cb5..df8681ee 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billingentity-form.component.ts @@ -144,7 +144,7 @@ export class BillingentityFormComponent implements OnInit { }); } - private isNewBe(be?: BillingEntity): boolean { + isNewBe(be?: BillingEntity): boolean { return be ? !!be.metadata.generateName : !!this.billingEntity.metadata.generateName; } From a540249e1d6762c88488f1ddb5300b4b7da6f20e Mon Sep 17 00:00:00 2001 From: ccremer Date: Fri, 14 Apr 2023 10:23:17 +0200 Subject: [PATCH 16/16] Rename billing entity components --- .../billing-entity-routing.module.ts | 8 ++++---- src/app/billingentity/billing-entity.module.ts | 16 ++++++++-------- ...html => billing-entity-detail.component.html} | 0 ...scss => billing-entity-detail.component.scss} | 0 ...ent.ts => billing-entity-detail.component.ts} | 6 +++--- ...t.html => billing-entity-form.component.html} | 2 +- ...t.scss => billing-entity-form.component.scss} | 0 ...onent.ts => billing-entity-form.component.ts} | 10 +++++----- ...tml => billing-entity-members.component.html} | 0 ...css => billing-entity-members.component.scss} | 0 ...nt.ts => billing-entity-members.component.ts} | 6 +++--- ...t.html => billing-entity-view.component.html} | 0 ...t.scss => billing-entity-view.component.scss} | 0 ...onent.ts => billing-entity-view.component.ts} | 6 +++--- 14 files changed, 27 insertions(+), 27 deletions(-) rename src/app/billingentity/billingentity-detail/{billingentity-detail.component.html => billing-entity-detail.component.html} (100%) rename src/app/billingentity/billingentity-detail/{billingentity-detail.component.scss => billing-entity-detail.component.scss} (100%) rename src/app/billingentity/billingentity-detail/{billingentity-detail.component.ts => billing-entity-detail.component.ts} (90%) rename src/app/billingentity/billingentity-form/{billingentity-form.component.html => billing-entity-form.component.html} (98%) rename src/app/billingentity/billingentity-form/{billingentity-form.component.scss => billing-entity-form.component.scss} (100%) rename src/app/billingentity/billingentity-form/{billingentity-form.component.ts => billing-entity-form.component.ts} (96%) rename src/app/billingentity/billingentity-members/{billingentity-members.component.html => billing-entity-members.component.html} (100%) rename src/app/billingentity/billingentity-members/{billingentity-members.component.scss => billing-entity-members.component.scss} (100%) rename src/app/billingentity/billingentity-members/{billingentity-members.component.ts => billing-entity-members.component.ts} (98%) rename src/app/billingentity/billingentity-view/{billingentity-view.component.html => billing-entity-view.component.html} (100%) rename src/app/billingentity/billingentity-view/{billingentity-view.component.scss => billing-entity-view.component.scss} (100%) rename src/app/billingentity/billingentity-view/{billingentity-view.component.ts => billing-entity-view.component.ts} (70%) diff --git a/src/app/billingentity/billing-entity-routing.module.ts b/src/app/billingentity/billing-entity-routing.module.ts index 426f21c7..9acef457 100644 --- a/src/app/billingentity/billing-entity-routing.module.ts +++ b/src/app/billingentity/billing-entity-routing.module.ts @@ -1,10 +1,10 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { BillingEntityComponent } from './billing-entity.component'; -import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; +import { BillingEntityDetailComponent } from './billingentity-detail/billing-entity-detail.component'; import { KubernetesPermissionGuard } from '../kubernetes-permission.guard'; import { BillingEntityPermissions } from '../types/billing-entity'; -import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; +import { BillingEntityMembersComponent } from './billingentity-members/billing-entity-members.component'; const routes: Routes = [ { @@ -17,7 +17,7 @@ const routes: Routes = [ }, { path: ':name', - component: BillingentityDetailComponent, + component: BillingEntityDetailComponent, canActivate: [KubernetesPermissionGuard], data: { requiredKubernetesPermissions: [{ ...BillingEntityPermissions, verb: 'list' }], @@ -25,7 +25,7 @@ const routes: Routes = [ }, { path: ':name/members', - component: BillingentityMembersComponent, + component: BillingEntityMembersComponent, canActivate: [KubernetesPermissionGuard], data: { requiredKubernetesPermissions: [{ ...BillingEntityPermissions, verb: 'list' }], diff --git a/src/app/billingentity/billing-entity.module.ts b/src/app/billingentity/billing-entity.module.ts index 1fe359b6..0a70050e 100644 --- a/src/app/billingentity/billing-entity.module.ts +++ b/src/app/billingentity/billing-entity.module.ts @@ -2,18 +2,18 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { BillingEntityComponent } from './billing-entity.component'; import { BillingEntityRoutingModule } from './billing-entity-routing.module'; -import { BillingentityDetailComponent } from './billingentity-detail/billingentity-detail.component'; -import { BillingentityMembersComponent } from './billingentity-members/billingentity-members.component'; -import { BillingentityViewComponent } from './billingentity-view/billingentity-view.component'; -import { BillingentityFormComponent } from './billingentity-form/billingentity-form.component'; +import { BillingEntityDetailComponent } from './billingentity-detail/billing-entity-detail.component'; +import { BillingEntityMembersComponent } from './billingentity-members/billing-entity-members.component'; +import { BillingEntityViewComponent } from './billingentity-view/billing-entity-view.component'; +import { BillingEntityFormComponent } from './billingentity-form/billing-entity-form.component'; @NgModule({ declarations: [ BillingEntityComponent, - BillingentityDetailComponent, - BillingentityMembersComponent, - BillingentityViewComponent, - BillingentityFormComponent, + BillingEntityDetailComponent, + BillingEntityMembersComponent, + BillingEntityViewComponent, + BillingEntityFormComponent, ], imports: [SharedModule, BillingEntityRoutingModule], }) diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.html b/src/app/billingentity/billingentity-detail/billing-entity-detail.component.html similarity index 100% rename from src/app/billingentity/billingentity-detail/billingentity-detail.component.html rename to src/app/billingentity/billingentity-detail/billing-entity-detail.component.html diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.scss b/src/app/billingentity/billingentity-detail/billing-entity-detail.component.scss similarity index 100% rename from src/app/billingentity/billingentity-detail/billingentity-detail.component.scss rename to src/app/billingentity/billingentity-detail/billing-entity-detail.component.scss diff --git a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts b/src/app/billingentity/billingentity-detail/billing-entity-detail.component.ts similarity index 90% rename from src/app/billingentity/billingentity-detail/billingentity-detail.component.ts rename to src/app/billingentity/billingentity-detail/billing-entity-detail.component.ts index f260bac5..2098dea1 100644 --- a/src/app/billingentity/billingentity-detail/billingentity-detail.component.ts +++ b/src/app/billingentity/billingentity-detail/billing-entity-detail.component.ts @@ -7,11 +7,11 @@ import { BillingEntityCollectionService } from '../../store/billingentity-collec @Component({ selector: 'app-billingentity-detail', - templateUrl: './billingentity-detail.component.html', - styleUrls: ['./billingentity-detail.component.scss'], + templateUrl: './billing-entity-detail.component.html', + styleUrls: ['./billing-entity-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BillingentityDetailComponent implements OnInit { +export class BillingEntityDetailComponent implements OnInit { viewModel$?: Observable; isEditing$?: Observable; billingEntityName = ''; diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.html b/src/app/billingentity/billingentity-form/billing-entity-form.component.html similarity index 98% rename from src/app/billingentity/billingentity-form/billingentity-form.component.html rename to src/app/billingentity/billingentity-form/billing-entity-form.component.html index f6829b38..9546b993 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.html +++ b/src/app/billingentity/billingentity-form/billing-entity-form.component.html @@ -95,7 +95,7 @@
-
+
By registering a billing address, you agree that you will get charged for using VSHN products. You also agree to our diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.scss b/src/app/billingentity/billingentity-form/billing-entity-form.component.scss similarity index 100% rename from src/app/billingentity/billingentity-form/billingentity-form.component.scss rename to src/app/billingentity/billingentity-form/billing-entity-form.component.scss diff --git a/src/app/billingentity/billingentity-form/billingentity-form.component.ts b/src/app/billingentity/billingentity-form/billing-entity-form.component.ts similarity index 96% rename from src/app/billingentity/billingentity-form/billingentity-form.component.ts rename to src/app/billingentity/billingentity-form/billing-entity-form.component.ts index df8681ee..74680074 100644 --- a/src/app/billingentity/billingentity-form/billingentity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billing-entity-form.component.ts @@ -13,11 +13,11 @@ import { filter } from 'rxjs'; @Component({ selector: 'app-billingentity-form', - templateUrl: './billingentity-form.component.html', - styleUrls: ['./billingentity-form.component.scss'], + templateUrl: './billing-entity-form.component.html', + styleUrls: ['./billing-entity-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BillingentityFormComponent implements OnInit { +export class BillingEntityFormComponent implements OnInit { @Input() billingEntity!: BillingEntity; @@ -123,7 +123,7 @@ export class BillingentityFormComponent implements OnInit { emails: controls.accountingEmail.value, }, }; - if (this.isNewBe(be)) { + if (this.isNewBillingEntity(be)) { this.billingService.add(be).subscribe({ next: (result) => this.saveOrUpdateSuccess(result), error: (err) => this.saveOrUpdateFailure(err), @@ -144,7 +144,7 @@ export class BillingentityFormComponent implements OnInit { }); } - isNewBe(be?: BillingEntity): boolean { + isNewBillingEntity(be?: BillingEntity): boolean { return be ? !!be.metadata.generateName : !!this.billingEntity.metadata.generateName; } diff --git a/src/app/billingentity/billingentity-members/billingentity-members.component.html b/src/app/billingentity/billingentity-members/billing-entity-members.component.html similarity index 100% rename from src/app/billingentity/billingentity-members/billingentity-members.component.html rename to src/app/billingentity/billingentity-members/billing-entity-members.component.html diff --git a/src/app/billingentity/billingentity-members/billingentity-members.component.scss b/src/app/billingentity/billingentity-members/billing-entity-members.component.scss similarity index 100% rename from src/app/billingentity/billingentity-members/billingentity-members.component.scss rename to src/app/billingentity/billingentity-members/billing-entity-members.component.scss diff --git a/src/app/billingentity/billingentity-members/billingentity-members.component.ts b/src/app/billingentity/billingentity-members/billing-entity-members.component.ts similarity index 98% rename from src/app/billingentity/billingentity-members/billingentity-members.component.ts rename to src/app/billingentity/billingentity-members/billing-entity-members.component.ts index 02015dda..c3338c7e 100644 --- a/src/app/billingentity/billingentity-members/billingentity-members.component.ts +++ b/src/app/billingentity/billingentity-members/billing-entity-members.component.ts @@ -27,11 +27,11 @@ interface Payload { @Component({ selector: 'app-billingentity-members', - templateUrl: './billingentity-members.component.html', - styleUrls: ['./billingentity-members.component.scss'], + templateUrl: './billing-entity-members.component.html', + styleUrls: ['./billing-entity-members.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BillingentityMembersComponent implements OnInit, OnDestroy { +export class BillingEntityMembersComponent implements OnInit, OnDestroy { payload$?: Observable; faWarning = faWarning; diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.html b/src/app/billingentity/billingentity-view/billing-entity-view.component.html similarity index 100% rename from src/app/billingentity/billingentity-view/billingentity-view.component.html rename to src/app/billingentity/billingentity-view/billing-entity-view.component.html diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.scss b/src/app/billingentity/billingentity-view/billing-entity-view.component.scss similarity index 100% rename from src/app/billingentity/billingentity-view/billingentity-view.component.scss rename to src/app/billingentity/billingentity-view/billing-entity-view.component.scss diff --git a/src/app/billingentity/billingentity-view/billingentity-view.component.ts b/src/app/billingentity/billingentity-view/billing-entity-view.component.ts similarity index 70% rename from src/app/billingentity/billingentity-view/billingentity-view.component.ts rename to src/app/billingentity/billingentity-view/billing-entity-view.component.ts index 904c8010..e90c0811 100644 --- a/src/app/billingentity/billingentity-view/billingentity-view.component.ts +++ b/src/app/billingentity/billingentity-view/billing-entity-view.component.ts @@ -4,11 +4,11 @@ import { BillingEntity } from '../../types/billing-entity'; @Component({ selector: 'app-billingentity-view', - templateUrl: './billingentity-view.component.html', - styleUrls: ['./billingentity-view.component.scss'], + templateUrl: './billing-entity-view.component.html', + styleUrls: ['./billing-entity-view.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BillingentityViewComponent { +export class BillingEntityViewComponent { @Input() billingEntity!: BillingEntity;