From e52a160d57e9bb29fba8c3289f3155609bce96f4 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Thu, 26 Oct 2023 13:34:27 +0200 Subject: [PATCH 001/132] basic initial entity config admin ui --- src/app/app.routing.ts | 5 ++ .../entity-form/entity-form.service.ts | 18 ++++-- .../entity-form/entity-form.component.ts | 7 ++- .../config-entity-form.component.html | 35 +++++++++++ .../config-entity-form.component.scss | 11 ++++ .../config-entity-form.component.spec.ts | 21 +++++++ .../config-entity-form.component.ts | 61 +++++++++++++++++++ .../config-entity.component.html | 38 ++++++++++++ .../config-entity.component.scss | 3 + .../config-entity.component.spec.ts | 21 +++++++ .../config-entity/config-entity.component.ts | 55 +++++++++++++++++ .../config-field/config-field.component.html | 1 + .../config-field/config-field.component.scss | 0 .../config-field.component.spec.ts | 21 +++++++ .../config-field/config-field.component.ts | 12 ++++ src/app/core/config-ui/config-ui.module.ts | 36 +++++++++++ .../entity-details.component.html | 18 ++++-- .../entity-details.component.ts | 3 +- .../entity-details/form/form.component.html | 2 +- .../entity-details/form/form.component.ts | 28 ++++++--- 20 files changed, 377 insertions(+), 19 deletions(-) create mode 100644 src/app/core/config-ui/config-entity-form/config-entity-form.component.html create mode 100644 src/app/core/config-ui/config-entity-form/config-entity-form.component.scss create mode 100644 src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts create mode 100644 src/app/core/config-ui/config-entity-form/config-entity-form.component.ts create mode 100644 src/app/core/config-ui/config-entity/config-entity.component.html create mode 100644 src/app/core/config-ui/config-entity/config-entity.component.scss create mode 100644 src/app/core/config-ui/config-entity/config-entity.component.spec.ts create mode 100644 src/app/core/config-ui/config-entity/config-entity.component.ts create mode 100644 src/app/core/config-ui/config-field/config-field.component.html create mode 100644 src/app/core/config-ui/config-field/config-field.component.scss create mode 100644 src/app/core/config-ui/config-field/config-field.component.spec.ts create mode 100644 src/app/core/config-ui/config-field/config-field.component.ts create mode 100644 src/app/core/config-ui/config-ui.module.ts diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index e560188ba9..e2e2074e69 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -60,6 +60,11 @@ export const allRoutes: Routes = [ (c) => c.PublicFormComponent, ), }, + { + path: "admin-ui", + loadChildren: () => + import("./core/config-ui/config-ui.module").then((m) => m.ConfigUiModule), + }, { path: "login", component: LoginComponent }, { path: "404", component: NotFoundComponent }, diff --git a/src/app/core/common-components/entity-form/entity-form.service.ts b/src/app/core/common-components/entity-form/entity-form.service.ts index 8a0e448100..06e4c1e45e 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.ts @@ -18,6 +18,10 @@ import { PLACEHOLDERS, } from "../../entity/schema/entity-schema-field"; import { isArrayDataType } from "../../basic-datatypes/datatype-utils"; +import { + ColumnConfig, + toFormFieldConfig, +} from "../entity-subrecord/entity-subrecord/entity-subrecord-config"; /** * These are utility types that allow to define the type of `FormGroup` the way it is returned by `EntityFormService.create` @@ -60,11 +64,12 @@ export class EntityFormService { * @param forTable */ public extendFormFieldConfig( - formFields: FormFieldConfig[], + formFields: ColumnConfig[], entityType: EntityConstructor, forTable = false, - ) { - formFields.forEach((formField) => { + ): FormFieldConfig[] { + const fullFields: FormFieldConfig[] = formFields.map(toFormFieldConfig); + fullFields.forEach((formField) => { try { this.addFormFields(formField, entityType, forTable); } catch (err) { @@ -73,6 +78,7 @@ export class EntityFormService { ); } }); + return fullFields; } private addFormFields( @@ -115,7 +121,11 @@ export class EntityFormService { entity: T, forTable = false, ): EntityForm { - this.extendFormFieldConfig(formFields, entity.getConstructor(), forTable); + formFields = this.extendFormFieldConfig( + formFields, + entity.getConstructor(), + forTable, + ); const formConfig = {}; const entitySchema = entity.getSchema(); const copy = entity.copy(); diff --git a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts index 7cdc2713ae..eb9c38ee7a 100644 --- a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts +++ b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts @@ -45,7 +45,7 @@ import { HelpButtonComponent } from "../../help-button/help-button.component"; standalone: true, }) export class EntityFormComponent - implements OnChanges + implements OnChanges, EntityFormConfig { /** * The entity which should be displayed and edited @@ -154,3 +154,8 @@ export class EntityFormComponent } } } + +export interface EntityFormConfig { + columns: FormFieldConfig[][]; + columnHeaders?: (string | null)[]; +} diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html new file mode 100644 index 0000000000..71640219d4 --- /dev/null +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -0,0 +1,35 @@ +
+ +
+

+ {{ fullConfig.columnHeaders[i] }} +

+ + +
+
+ + +
+ +
+ +
+
diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss new file mode 100644 index 0000000000..c7ae86b660 --- /dev/null +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -0,0 +1,11 @@ +@use "src/styles/variables/colors"; +@use "src/styles/variables/sizes"; + +.admin-form-field:hover { + background-color: colors.$grey-transparent; +} +.admin-form-field { + padding: sizes.$small; + } + +:host ::ng-deep input[disabled] { pointer-events: none } diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts new file mode 100644 index 0000000000..fbb383730d --- /dev/null +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfigEntityFormComponent } from './config-entity-form.component'; + +describe('ConfigEntityFormComponent', () => { + let component: ConfigEntityFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ConfigEntityFormComponent] + }); + fixture = TestBed.createComponent(ConfigEntityFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts new file mode 100644 index 0000000000..87fa2164eb --- /dev/null +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -0,0 +1,61 @@ +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { EntityFormConfig } from "../../common-components/entity-form/entity-form/entity-form.component"; +import { Entity, EntityConstructor } from "../../entity/model/entity"; +import { FormConfig } from "../../entity-details/form/form.component"; +import { EntityFormService } from "../../common-components/entity-form/entity-form.service"; +import { FormGroup } from "@angular/forms"; +import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; +import { MatDialog } from "@angular/material/dialog"; +import { ConfigFieldComponent } from "../config-field/config-field.component"; + +@Component({ + selector: "app-config-entity-form", + templateUrl: "./config-entity-form.component.html", + styleUrls: [ + "./config-entity-form.component.scss", + "../../common-components/entity-form/entity-form/entity-form.component.scss", + ], +}) +export class ConfigEntityFormComponent implements OnChanges { + @Input() entityType: EntityConstructor; + @Input() config: FormConfig; + fullConfig: EntityFormConfig; + + dummyEntity: Entity; + dummyForm: FormGroup; + + constructor( + private entityFormService: EntityFormService, + private matDialog: MatDialog, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.config) { + this.prepareConfig(this.config); + } + } + + private prepareConfig(config: FormConfig) { + this.fullConfig = { + columns: config.cols.map((fields) => + this.entityFormService.extendFormFieldConfig(fields, this.entityType), + ), + columnHeaders: config.headers, + }; + + this.dummyEntity = new this.entityType(); + this.dummyForm = this.entityFormService.createFormGroup( + [].concat(...this.fullConfig.columns), + this.dummyEntity, + ); + this.dummyForm.disable(); + } + + openFieldConfig(field: FormFieldConfig) { + this.matDialog.open(ConfigFieldComponent, { + width: "99%", + height: "90vh", + data: { fieldConfig: field, entityType: this.entityType }, + }); + } +} diff --git a/src/app/core/config-ui/config-entity/config-entity.component.html b/src/app/core/config-ui/config-entity/config-entity.component.html new file mode 100644 index 0000000000..f77c013d02 --- /dev/null +++ b/src/app/core/config-ui/config-entity/config-entity.component.html @@ -0,0 +1,38 @@ +
+ + Editing data structure for "{{ entityType | entityTypeLabel }}" records + +
+ + + + + + {{ panelConfig.title }} + + + + +
+

+ {{ componentConfig.title }} +

+ + + + + other component: {{ componentConfig.component }} + + +
+
+
+
+
diff --git a/src/app/core/config-ui/config-entity/config-entity.component.scss b/src/app/core/config-ui/config-entity/config-entity.component.scss new file mode 100644 index 0000000000..7f32186939 --- /dev/null +++ b/src/app/core/config-ui/config-entity/config-entity.component.scss @@ -0,0 +1,3 @@ +.admin-ui-title { + font-style: italic; +} diff --git a/src/app/core/config-ui/config-entity/config-entity.component.spec.ts b/src/app/core/config-ui/config-entity/config-entity.component.spec.ts new file mode 100644 index 0000000000..f1bff7ed26 --- /dev/null +++ b/src/app/core/config-ui/config-entity/config-entity.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfigEntityComponent } from './config-entity.component'; + +describe('ConfigEntityComponent', () => { + let component: ConfigEntityComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ConfigEntityComponent] + }); + fixture = TestBed.createComponent(ConfigEntityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/config-ui/config-entity/config-entity.component.ts b/src/app/core/config-ui/config-entity/config-entity.component.ts new file mode 100644 index 0000000000..6879d7e349 --- /dev/null +++ b/src/app/core/config-ui/config-entity/config-entity.component.ts @@ -0,0 +1,55 @@ +import { Component, Input } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { EntityDetailsConfig } from "../../entity-details/EntityDetailsConfig"; +import { EntityConstructor } from "../../entity/model/entity"; +import { EntityRegistry } from "../../entity/database-entity.decorator"; +import { ConfigService } from "../../config/config.service"; +import { + PREFIX_VIEW_CONFIG, + ViewConfig, +} from "../../config/dynamic-routing/view-config.interface"; + +@Component({ + selector: "app-config-entity", + templateUrl: "./config-entity.component.html", + styleUrls: ["./config-entity.component.scss"], +}) +export class ConfigEntityComponent { + @Input() entityType: string; + entityConstructor: EntityConstructor; + + configDetailsView: EntityDetailsConfig; + + constructor( + route: ActivatedRoute, + private entities: EntityRegistry, + private configService: ConfigService, + ) { + route.paramMap.subscribe((params) => { + this.entityType = params.get("entityType"); + this.init(); + }); + } + + private init() { + this.entityConstructor = this.entities.get(this.entityType); + + const detailsView: ViewConfig = + this.configService.getConfig( + PREFIX_VIEW_CONFIG + + this.entityConstructor.route.replace(/^\//, "") + + "/:id", + ); + if (detailsView.component !== "EntityDetails") { + // not supported currently + return; + } + this.configDetailsView = detailsView.config; + + console.log( + "configDetailsView", + PREFIX_VIEW_CONFIG + this.entityConstructor.route + "/:id", + this.configDetailsView, + ); + } +} diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html new file mode 100644 index 0000000000..d469ede484 --- /dev/null +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -0,0 +1 @@ +

config-field works!

diff --git a/src/app/core/config-ui/config-field/config-field.component.scss b/src/app/core/config-ui/config-field/config-field.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/config-ui/config-field/config-field.component.spec.ts b/src/app/core/config-ui/config-field/config-field.component.spec.ts new file mode 100644 index 0000000000..e07a584d6d --- /dev/null +++ b/src/app/core/config-ui/config-field/config-field.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfigFieldComponent } from './config-field.component'; + +describe('ConfigFieldComponent', () => { + let component: ConfigFieldComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ConfigFieldComponent] + }); + fixture = TestBed.createComponent(ConfigFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts new file mode 100644 index 0000000000..5049e3cd89 --- /dev/null +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -0,0 +1,12 @@ +import { Component } from "@angular/core"; + +/** + * Allows configuration of the schema of a single Entity field, like its dataType and labels. + */ +@Component({ + selector: "app-config-field", + templateUrl: "./config-field.component.html", + styleUrls: ["./config-field.component.scss"], + standalone: true, +}) +export class ConfigFieldComponent {} diff --git a/src/app/core/config-ui/config-ui.module.ts b/src/app/core/config-ui/config-ui.module.ts new file mode 100644 index 0000000000..89d0838ddb --- /dev/null +++ b/src/app/core/config-ui/config-ui.module.ts @@ -0,0 +1,36 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ConfigEntityComponent } from "./config-entity/config-entity.component"; +import { RouterModule, Routes } from "@angular/router"; +import { MatTabsModule } from "@angular/material/tabs"; +import { ViewTitleComponent } from "../common-components/view-title/view-title.component"; +import { EntityTypeLabelPipe } from "../common-components/entity-type-label/entity-type-label.pipe"; +import { ConfigEntityFormComponent } from "./config-entity-form/config-entity-form.component"; +import { HelpButtonComponent } from "../common-components/help-button/help-button.component"; +import { DynamicComponentDirective } from "../config/dynamic-components/dynamic-component.directive"; + +const routes: Routes = [ + { + path: "entity/:entityType", + component: ConfigEntityComponent, + }, +]; + +/** + * An intuitive UI for users to set up and configure the application's data structures and views + * directly from within the app itself. + */ +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(routes), + ViewTitleComponent, + EntityTypeLabelPipe, + MatTabsModule, + HelpButtonComponent, + DynamicComponentDirective, + ], + exports: [RouterModule], + declarations: [ConfigEntityComponent, ConfigEntityFormComponent], +}) +export class ConfigUiModule {} diff --git a/src/app/core/entity-details/entity-details/entity-details.component.html b/src/app/core/entity-details/entity-details/entity-details.component.html index 0592902976..8731c57bb1 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.html +++ b/src/app/core/entity-details/entity-details/entity-details.component.html @@ -28,10 +28,20 @@ Adding new {{ this.entityConstructor?.label }} - + + + diff --git a/src/app/core/entity-details/entity-details/entity-details.component.ts b/src/app/core/entity-details/entity-details/entity-details.component.ts index ffb039f8d3..4914911b24 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.ts +++ b/src/app/core/entity-details/entity-details/entity-details.component.ts @@ -1,5 +1,5 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { EntityDetailsConfig, Panel, @@ -58,6 +58,7 @@ import { EntityArchivedInfoComponent } from "../entity-archived-info/entity-arch DisableEntityOperationDirective, EntityActionsMenuComponent, EntityArchivedInfoComponent, + RouterLink, ], }) export class EntityDetailsComponent { diff --git a/src/app/core/entity-details/form/form.component.html b/src/app/core/entity-details/form/form.component.html index 8fe88ce3e6..fcddf19546 100644 --- a/src/app/core/entity-details/form/form.component.html +++ b/src/app/core/entity-details/form/form.component.html @@ -37,7 +37,7 @@ diff --git a/src/app/core/entity-details/form/form.component.ts b/src/app/core/entity-details/form/form.component.ts index 5b214fe7de..c29bfd0142 100644 --- a/src/app/core/entity-details/form/form.component.ts +++ b/src/app/core/entity-details/form/form.component.ts @@ -1,6 +1,5 @@ import { Component, Input, OnInit } from "@angular/core"; import { Entity } from "../../entity/model/entity"; -import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; import { getParentUrl } from "../../../utils/utils"; import { Router } from "@angular/router"; import { Location, NgIf } from "@angular/common"; @@ -11,10 +10,11 @@ import { EntityFormService, } from "../../common-components/entity-form/entity-form.service"; import { AlertService } from "../../alerts/alert.service"; -import { toFormFieldConfig } from "../../common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; +import { ColumnConfig } from "../../common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; import { MatButtonModule } from "@angular/material/button"; import { EntityFormComponent } from "../../common-components/entity-form/entity-form/entity-form.component"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; +import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; /** * A simple wrapper function of the EntityFormComponent which can be used as a dynamic component @@ -33,16 +33,14 @@ import { DisableEntityOperationDirective } from "../../permissions/permission-di ], standalone: true, }) -export class FormComponent implements OnInit { +export class FormComponent implements FormConfig, OnInit { @Input() entity: E; @Input() creatingNew = false; @Input() headers: string[] = []; - @Input() set cols(value: FormFieldConfig[][]) { - this._cols = value.map((row) => row.map(toFormFieldConfig)); - } + @Input() cols: ColumnConfig[][]; + columns: FormFieldConfig[][] = []; - _cols: FormFieldConfig[][] = []; form: EntityForm; constructor( @@ -53,8 +51,14 @@ export class FormComponent implements OnInit { ) {} ngOnInit() { + this.columns = this.cols.map((fields) => + this.entityFormService.extendFormFieldConfig( + fields, + this.entity.getConstructor(), + ), + ); this.form = this.entityFormService.createFormGroup( - [].concat(...this._cols), + [].concat(...this.columns), this.entity, ); if (!this.creatingNew) { @@ -88,3 +92,11 @@ export class FormComponent implements OnInit { this.form.disable(); } } + +/** + * The (possibly abbreviated) configuration for a "FormComponent", as it is stored in the config file. + */ +export interface FormConfig { + cols: ColumnConfig[][]; + headers: string[]; +} From 2aaa83dc6c2551f41c0b8ff69e18b1a76644a852 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Sat, 28 Oct 2023 15:17:09 +0200 Subject: [PATCH 002/132] refactor(core): implement RoutedViewComponent to generalize loading of config from route data --- .../concepts/configuration.md | 4 +- .../add-day-attendance.component.html | 2 +- .../add-day-attendance.component.ts | 15 +--- .../children-list/children-list.component.ts | 2 + .../notes-manager.component.spec.ts | 11 +-- .../notes-manager/notes-manager.component.ts | 6 +- .../dynamic-routing/router.service.spec.ts | 30 +++---- .../config/dynamic-routing/router.service.ts | 19 ++--- .../dynamic-routing/view-config.interface.ts | 11 ++- .../dashboard/dashboard.component.spec.ts | 30 ------- .../dashboard/dashboard.component.ts | 20 ++--- .../entity-details.component.html | 13 +-- .../entity-details.component.spec.ts | 46 +++++------ .../entity-details.component.ts | 79 ++++++++++--------- .../entity-details/entity-details.stories.ts | 15 +--- .../entity-list/entity-list.component.ts | 1 + .../ui/routed-view/routed-view.component.html | 1 + .../ui/routed-view/routed-view.component.scss | 0 .../routed-view/routed-view.component.spec.ts | 76 ++++++++++++++++++ .../ui/routed-view/routed-view.component.ts | 47 +++++++++++ src/app/core/ui/ui-config.ts | 27 ------- .../markdown-page/markdown-page.component.ts | 16 +--- .../reporting/reporting.component.html | 2 +- .../reporting/reporting.component.ts | 15 +--- .../todos/todo-list/todo-list.component.ts | 1 + 25 files changed, 253 insertions(+), 236 deletions(-) create mode 100644 src/app/core/ui/routed-view/routed-view.component.html create mode 100644 src/app/core/ui/routed-view/routed-view.component.scss create mode 100644 src/app/core/ui/routed-view/routed-view.component.spec.ts create mode 100644 src/app/core/ui/routed-view/routed-view.component.ts delete mode 100644 src/app/core/ui/ui-config.ts diff --git a/doc/compodoc_sources/concepts/configuration.md b/doc/compodoc_sources/concepts/configuration.md index 442393ebdc..863f5fc87e 100644 --- a/doc/compodoc_sources/concepts/configuration.md +++ b/doc/compodoc_sources/concepts/configuration.md @@ -35,7 +35,9 @@ The config service provides a behavior subject which will notify all subscribers This can be used for core tasks like setting up the routes or creating the navigation bar. Top-level "view" components (i.e. components that are used to define a whole page, not just some building block for a part or section) -receive their config data through the standard Angular router and can access it by injecting `ActivatedRoute`. +receive their config data automatically assigned as `@Input()` properties mapped from config object property name to an identical component class property. +This is handled by the `RoutedViewComponent` internally. +(If needed for special cases, you can also access it through the standard Angular router and can access it by injecting `ActivatedRoute`.) ### Storing config in DB diff --git a/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.html b/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.html index de9808bda5..07fc624d8c 100644 --- a/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.html +++ b/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.html @@ -29,7 +29,7 @@

{{ event.subject }}

diff --git a/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.ts b/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.ts index ad53d635fc..2458064af2 100644 --- a/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.ts +++ b/src/app/child-dev-project/attendance/add-day-attendance/add-day-attendance.component.ts @@ -1,11 +1,9 @@ -import { Component, ViewChild } from "@angular/core"; +import { Component, Input, ViewChild } from "@angular/core"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; import { Note } from "../../notes/model/note"; import { ConfirmationDialogService } from "../../../core/common-components/confirmation-dialog/confirmation-dialog.service"; import { ConfirmationDialogButton } from "../../../core/common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component"; import { RollCallComponent } from "./roll-call/roll-call.component"; -import { ActivatedRoute } from "@angular/router"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; import { RouteTarget } from "../../../app.routing"; import { NgIf } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; @@ -38,8 +36,8 @@ export interface AddDayAttendanceConfig { ], standalone: true, }) -export class AddDayAttendanceComponent { - config?: AddDayAttendanceConfig; +export class AddDayAttendanceComponent implements AddDayAttendanceConfig { + @Input() sortParticipantsBy: any; currentStage = 0; @@ -75,13 +73,8 @@ export class AddDayAttendanceComponent { constructor( private entityMapper: EntityMapperService, - private route: ActivatedRoute, private confirmationDialog: ConfirmationDialogService, - ) { - this.route.data.subscribe((data: RouteData) => { - this.config = data.config; - }); - } + ) {} finishBasicInformationStage(event: Note) { this.event = event; diff --git a/src/app/child-dev-project/children/children-list/children-list.component.ts b/src/app/child-dev-project/children/children-list/children-list.component.ts index f692d2d3f4..03fd7dc774 100644 --- a/src/app/child-dev-project/children/children-list/children-list.component.ts +++ b/src/app/child-dev-project/children/children-list/children-list.component.ts @@ -34,6 +34,8 @@ export class ChildrenListComponent implements OnInit { async ngOnInit() { this.route.data.subscribe( + // TODO replace this use of route and rely on the RoutedViewComponent instead + // see that flattens the config option, assigning individual properties as inputs however, so we can't easily pass on (data: RouteData) => (this.listConfig = data.config), ); this.childrenList = await this.childrenService.getChildren(); diff --git a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.spec.ts b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.spec.ts index 92422aa17a..0c1238b8fa 100644 --- a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.spec.ts +++ b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.spec.ts @@ -1,7 +1,4 @@ -import { - NotesManagerComponent, - NotesManagerConfig, -} from "./notes-manager.component"; +import { NotesManagerComponent } from "./notes-manager.component"; import { ComponentFixture, fakeAsync, @@ -191,11 +188,9 @@ describe("NotesManagerComponent", () => { entityMapper.save(eventNote); tick(); + component.includeEventNotes = true; routeMock.data.next({ - config: Object.assign( - { includeEventNotes: true } as NotesManagerConfig, - routeData, - ), + config: routeData, }); flush(); diff --git a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts index e38943d871..a7ae7e67b7 100644 --- a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts +++ b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts @@ -4,7 +4,6 @@ import { NoteDetailsComponent } from "../note-details/note-details.component"; import { ActivatedRoute } from "@angular/router"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; import { FilterSelectionOption } from "../../../core/filter/filters/filters"; -import { SessionService } from "../../../core/session/session-service/session.service"; import { FormDialogService } from "../../../core/form-dialog/form-dialog.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { LoggingService } from "../../../core/logging/logging.service"; @@ -92,7 +91,6 @@ export class NotesManagerComponent implements OnInit { constructor( private formDialog: FormDialogService, - private sessionService: SessionService, private entityMapperService: EntityMapperService, private route: ActivatedRoute, private log: LoggingService, @@ -101,11 +99,9 @@ export class NotesManagerComponent implements OnInit { async ngOnInit() { this.route.data.subscribe( async (data: RouteData) => { + // TODO replace this use of route and rely on the RoutedViewComponent instead this.config = data.config; this.addPrebuiltFilters(); - - this.includeEventNotes = data.config.includeEventNotes; - this.showEventNotesToggle = data.config.showEventNotesToggle; this.notes = await this.loadEntities(); }, ); diff --git a/src/app/core/config/dynamic-routing/router.service.spec.ts b/src/app/core/config/dynamic-routing/router.service.spec.ts index e78ab00c14..d4b4dcca37 100644 --- a/src/app/core/config/dynamic-routing/router.service.spec.ts +++ b/src/app/core/config/dynamic-routing/router.service.spec.ts @@ -10,9 +10,9 @@ import { ViewConfig } from "./view-config.interface"; import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; import { ApplicationLoadingComponent } from "./empty/application-loading.component"; import { NotFoundComponent } from "./not-found/not-found.component"; -import { componentRegistry } from "../../../dynamic-components"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { AuthGuard } from "../../session/auth.guard"; +import { RoutedViewComponent } from "../../ui/routed-view/routed-view.component"; class TestComponent extends Component {} @@ -63,24 +63,24 @@ describe("RouterService", () => { const expectedRoutes = [ { path: "child", - loadComponent: componentRegistry.get("ChildrenList"), - data: {}, + component: RoutedViewComponent, + data: { component: "ChildrenList" }, canDeactivate: [jasmine.any(Function)], canActivate: [AuthGuard], }, { path: "child/:id", - loadComponent: componentRegistry.get("EntityDetails"), - data: { config: testViewConfig }, + component: RoutedViewComponent, + data: { component: "EntityDetails", config: testViewConfig }, canDeactivate: [jasmine.any(Function)], canActivate: [AuthGuard], }, { path: "list", - loadComponent: componentRegistry.get("EntityList"), + component: RoutedViewComponent, + data: { component: "EntityList", permittedUserRoles: ["user_app"] }, canActivate: [AuthGuard, UserRoleGuard], canDeactivate: [jasmine.any(Function)], - data: { permittedUserRoles: ["user_app"] }, }, ]; @@ -144,9 +144,11 @@ describe("RouterService", () => { const router = TestBed.inject(Router); expect(router.config.find((r) => r.path === "child").data).toEqual({ + component: "ChildrenList", config: { foo: 1 }, }); expect(router.config.find((r) => r.path === "child2").data).toEqual({ + component: "ChildrenList", config: { foo: 2 }, }); }); @@ -162,10 +164,10 @@ describe("RouterService", () => { const expectedRoutes = [ { path: "list", - loadComponent: componentRegistry.get("EntityList"), + component: RoutedViewComponent, + data: { component: "EntityList", permittedUserRoles: ["admin"] }, canActivate: [AuthGuard, UserRoleGuard], canDeactivate: [jasmine.any(Function)], - data: { permittedUserRoles: ["admin"] }, }, ]; const router = TestBed.inject(Router); @@ -186,14 +188,4 @@ describe("RouterService", () => { expect(wildcardRoute).toEqual({ path: "**", component: NotFoundComponent }); }); - - it("should log a warning if a view config has a component which is not registered", () => { - const testViewConfigs: ViewConfig[] = [ - { _id: "view:child", component: "Support" }, - ]; - - service.reloadRouting(testViewConfigs); - - expect(mockLoggingService.warn).toHaveBeenCalled(); - }); }); diff --git a/src/app/core/config/dynamic-routing/router.service.ts b/src/app/core/config/dynamic-routing/router.service.ts index d279b85737..f087dfd363 100644 --- a/src/app/core/config/dynamic-routing/router.service.ts +++ b/src/app/core/config/dynamic-routing/router.service.ts @@ -2,16 +2,12 @@ import { inject, Injectable } from "@angular/core"; import { Route, Router } from "@angular/router"; import { ConfigService } from "../config.service"; import { LoggingService } from "../../logging/logging.service"; -import { - PREFIX_VIEW_CONFIG, - RouteData, - ViewConfig, -} from "./view-config.interface"; +import { PREFIX_VIEW_CONFIG, ViewConfig } from "./view-config.interface"; import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; import { NotFoundComponent } from "./not-found/not-found.component"; -import { ComponentRegistry } from "../../../dynamic-components"; import { AuthGuard } from "../../session/auth.guard"; import { UnsavedChangesService } from "../../entity-details/form/unsaved-changes.service"; +import { RoutedViewComponent } from "../../ui/routed-view/routed-view.component"; /** * The RouterService dynamically sets up Angular routing from config loaded through the {@link ConfigService}. @@ -27,7 +23,6 @@ export class RouterService { private configService: ConfigService, private router: Router, private loggingService: LoggingService, - private components: ComponentRegistry, ) {} /** @@ -82,14 +77,15 @@ export class RouterService { return this.generateRouteFromConfig(view, route); } else { return this.generateRouteFromConfig(view, { - loadComponent: this.components.get(view.component), path, + component: RoutedViewComponent, + data: { component: view.component }, }); } } private generateRouteFromConfig(view: ViewConfig, route: Route): Route { - const routeData: RouteData = {}; + route.data = route.data ?? {}; route.canActivate = [AuthGuard]; route.canDeactivate = [ () => inject(UnsavedChangesService).checkUnsavedChanges(), @@ -97,14 +93,13 @@ export class RouterService { if (view.permittedUserRoles) { route.canActivate.push(UserRoleGuard); - routeData.permittedUserRoles = view.permittedUserRoles; + route.data.permittedUserRoles = view.permittedUserRoles; } if (view.config) { - routeData.config = view.config; + route.data.config = view.config; } - route.data = routeData; return route; } } diff --git a/src/app/core/config/dynamic-routing/view-config.interface.ts b/src/app/core/config/dynamic-routing/view-config.interface.ts index 6f6f4dd94f..c2d311ba55 100644 --- a/src/app/core/config/dynamic-routing/view-config.interface.ts +++ b/src/app/core/config/dynamic-routing/view-config.interface.ts @@ -44,10 +44,13 @@ export const PREFIX_VIEW_CONFIG = "view:"; * It contains static data which are used to build components and manage permissions. * The generic type defines the interface for the component specific configuration. * - * It can be accessed through the activated route: - * ``` - * constructor(private route: ActivatedRoute) { - * this.route.data.subscribe(routeData: RouteData => { ...what to do with the data })' + * The properties given in the `config` object here are automatically assigned to the component as `@Input()` properties. + * e.g. for an RouteData `{ config: { "entityType: "Child", "filtered": true } }` + * your component `MyViewComponent` will receive the values mapped to its properties: + * ```javascript + * class MyViewComponent { + * @Input() entityType: string; + * @Input() filtered: boolean; * } * ``` */ diff --git a/src/app/core/dashboard/dashboard/dashboard.component.spec.ts b/src/app/core/dashboard/dashboard/dashboard.component.spec.ts index 4c5692beb7..27b11bd14b 100644 --- a/src/app/core/dashboard/dashboard/dashboard.component.spec.ts +++ b/src/app/core/dashboard/dashboard/dashboard.component.spec.ts @@ -1,26 +1,13 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { DashboardComponent } from "./dashboard.component"; -import { ActivatedRoute } from "@angular/router"; -import { BehaviorSubject } from "rxjs"; -import { RouteData } from "../../config/dynamic-routing/view-config.interface"; -import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; describe("DashboardComponent", () => { let component: DashboardComponent; let fixture: ComponentFixture; - let mockRouteData: BehaviorSubject< - RouteData<{ widgets: DynamicComponentConfig[] }> - >; - beforeEach(() => { - mockRouteData = new BehaviorSubject({ config: { widgets: [] } }); - TestBed.configureTestingModule({ imports: [DashboardComponent], - providers: [ - { provide: ActivatedRoute, useValue: { data: mockRouteData } }, - ], }).compileComponents(); }); @@ -33,21 +20,4 @@ describe("DashboardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - it("should init with widget config from activated route", () => { - const testDashboardConfig = { - widgets: [ - { - component: "ProgressDashboard", - }, - { - component: "ProgressDashboard", - }, - ], - }; - - mockRouteData.next({ config: testDashboardConfig }); - - expect(component.widgets).toEqual(testDashboardConfig.widgets); - }); }); diff --git a/src/app/core/dashboard/dashboard/dashboard.component.ts b/src/app/core/dashboard/dashboard/dashboard.component.ts index 0ea0fd0444..e0ca13a494 100644 --- a/src/app/core/dashboard/dashboard/dashboard.component.ts +++ b/src/app/core/dashboard/dashboard/dashboard.component.ts @@ -15,10 +15,8 @@ * along with ndb-core. If not, see . */ -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { Component, Input } from "@angular/core"; import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; -import { RouteData } from "../../config/dynamic-routing/view-config.interface"; import { RouteTarget } from "../../../app.routing"; import { NgFor } from "@angular/common"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; @@ -34,16 +32,10 @@ import { DynamicComponentDirective } from "../../config/dynamic-components/dynam imports: [NgFor, DynamicComponentDirective], standalone: true, }) -export class DashboardComponent implements OnInit { - widgets: DynamicComponentConfig[] = []; - - constructor(private activatedRoute: ActivatedRoute) {} +export class DashboardComponent implements DashboardConfig { + @Input() widgets: DynamicComponentConfig[] = []; +} - ngOnInit() { - this.activatedRoute.data.subscribe( - (data: RouteData<{ widgets: DynamicComponentConfig[] }>) => { - this.widgets = data.config.widgets; - }, - ); - } +export interface DashboardConfig { + widgets: DynamicComponentConfig[]; } diff --git a/src/app/core/entity-details/entity-details/entity-details.component.html b/src/app/core/entity-details/entity-details/entity-details.component.html index 0592902976..7f0d90040c 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.html +++ b/src/app/core/entity-details/entity-details/entity-details.component.html @@ -17,7 +17,7 @@
- {{ entity?.toString() }} + {{ record?.toString() }}
- + @@ -69,7 +69,10 @@

{{ componentConfig.title }}

- +
diff --git a/src/app/core/entity-details/entity-details/entity-details.component.spec.ts b/src/app/core/entity-details/entity-details/entity-details.component.spec.ts index 796b31eb03..3399393f25 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.spec.ts +++ b/src/app/core/entity-details/entity-details/entity-details.component.spec.ts @@ -6,8 +6,7 @@ import { waitForAsync, } from "@angular/core/testing"; import { EntityDetailsComponent } from "./entity-details.component"; -import { Observable, of, Subscriber } from "rxjs"; -import { ActivatedRoute, Router } from "@angular/router"; +import { Router } from "@angular/router"; import { EntityDetailsConfig, PanelConfig } from "../EntityDetailsConfig"; import { Child } from "../../../child-dev-project/children/model/child"; import { ChildrenService } from "../../../child-dev-project/children/children.service"; @@ -15,13 +14,12 @@ import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { EntityRemoveService } from "../../entity/entity-remove.service"; import { EntityAbility } from "../../permissions/ability/entity-ability"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { SimpleChange } from "@angular/core"; describe("EntityDetailsComponent", () => { let component: EntityDetailsComponent; let fixture: ComponentFixture; - let routeObserver: Subscriber; - const routeConfig: EntityDetailsConfig = { entity: "Child", panels: [ @@ -44,18 +42,6 @@ describe("EntityDetailsComponent", () => { }, ], }; - const mockedRoute = { - paramMap: new Observable((observer) => { - routeObserver = observer; - observer.next({ get: () => "new" }); - }), - data: of({ config: routeConfig }), - snapshot: { - queryParamMap: { - get: () => "", - }, - }, - }; let mockChildrenService: jasmine.SpyObj; let mockEntityRemoveService: jasmine.SpyObj; @@ -74,7 +60,6 @@ describe("EntityDetailsComponent", () => { TestBed.configureTestingModule({ imports: [EntityDetailsComponent, MockedTestingModule.withState()], providers: [ - { provide: ActivatedRoute, useValue: mockedRoute }, { provide: ChildrenService, useValue: mockChildrenService }, { provide: EntityRemoveService, useValue: mockEntityRemoveService }, { provide: EntityAbility, useValue: mockAbility }, @@ -85,6 +70,12 @@ describe("EntityDetailsComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(EntityDetailsComponent); component = fixture.componentInstance; + + Object.assign(component, routeConfig); + component.ngOnChanges( + simpleChangesFor(component, ...Object.keys(routeConfig)), + ); + fixture.detectChanges(); }); @@ -97,10 +88,11 @@ describe("EntityDetailsComponent", () => { TestBed.inject(EntityMapperService).save(testChild); tick(); component.creatingNew = false; - routeObserver.next({ get: () => testChild.getId() }); + component.id = testChild.getId(); + component.ngOnChanges(simpleChangesFor(component, "id")); tick(); - component.panels.forEach((p) => + component.panelsComponents.forEach((p) => p.components.forEach((c) => { const panelConfig = c.config as PanelConfig; expect(panelConfig.entity).toEqual(testChild); @@ -117,12 +109,13 @@ describe("EntityDetailsComponent", () => { tick(); spyOn(entityMapper, "load").and.callThrough(); - routeObserver.next({ get: () => testChild.getId() }); + component.id = testChild.getId(); + component.ngOnChanges(simpleChangesFor(component, "id")); expect(component.isLoading).toBeTrue(); tick(); expect(entityMapper.load).toHaveBeenCalledWith(Child, testChild.getId()); - expect(component.entity).toBe(testChild); + expect(component.record).toBe(testChild); expect(component.isLoading).toBeFalse(); })); @@ -130,7 +123,16 @@ describe("EntityDetailsComponent", () => { mockAbility.cannot.and.returnValue(true); const router = fixture.debugElement.injector.get(Router); spyOn(router, "navigate"); - routeObserver.next({ get: () => "new" }); + component.id = "new"; + component.ngOnChanges(simpleChangesFor(component, "id")); expect(router.navigate).toHaveBeenCalled(); }); }); + +function simpleChangesFor(component, ...properties: string[]) { + const changes = {}; + for (const p of properties) { + changes[p] = new SimpleChange(null, component[p], true); + } + return changes; +} diff --git a/src/app/core/entity-details/entity-details/entity-details.component.ts b/src/app/core/entity-details/entity-details/entity-details.component.ts index ffb039f8d3..4cf8adde80 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.ts +++ b/src/app/core/entity-details/entity-details/entity-details.component.ts @@ -1,5 +1,5 @@ -import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { Router } from "@angular/router"; import { EntityDetailsConfig, Panel, @@ -8,7 +8,6 @@ import { } from "../EntityDetailsConfig"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; -import { RouteData } from "../../config/dynamic-routing/view-config.interface"; import { AnalyticsService } from "../../analytics/analytics.service"; import { EntityAbility } from "../../permissions/ability/entity-ability"; import { RouteTarget } from "../../../app.routing"; @@ -60,64 +59,67 @@ import { EntityArchivedInfoComponent } from "../entity-archived-info/entity-arch EntityArchivedInfoComponent, ], }) -export class EntityDetailsComponent { - entity: Entity; +export class EntityDetailsComponent implements EntityDetailsConfig, OnChanges { creatingNew = false; isLoading = true; - panels: Panel[] = []; - config: EntityDetailsConfig; + /** @deprecated use "entityType" instead, this remains for config backwards compatibility */ + @Input() set entity(v: string) { + this.entityType = v; + } + @Input() entityType: string; entityConstructor: EntityConstructor; + @Input() id: string; + record: Entity; + + @Input() panels: Panel[] = []; + /** the actual, fully resolved panel configs */ + panelsComponents: Panel[] = []; + constructor( private entityMapperService: EntityMapperService, - private route: ActivatedRoute, private router: Router, private analyticsService: AnalyticsService, private ability: EntityAbility, private entities: EntityRegistry, private logger: LoggingService, public unsavedChanges: UnsavedChangesService, - ) { - this.route.data.subscribe((data: RouteData) => { - this.config = data.config; - this.entityConstructor = this.entities.get(this.config.entity); - this.setInitialPanelsConfig(); - this.route.paramMap.subscribe((params) => - this.loadEntity(params.get("id")), - ); - }); + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.entity || changes.entityType) { + this.entityConstructor = this.entities.get(this.entityType); + } + if (changes.id) { + this.loadEntity(this.id); + } + if (changes.panels) { + this.initPanels(); + } } - private loadEntity(id: string) { + private async loadEntity(id: string) { if (id === "new") { if (this.ability.cannot("create", this.entityConstructor)) { this.router.navigate([""]); return; } - this.entity = new this.entityConstructor(); + this.record = new this.entityConstructor(); this.creatingNew = true; - this.setFullPanelsConfig(); } else { this.creatingNew = false; - this.entityMapperService - .load(this.entityConstructor, id) - .then((entity) => { - this.entity = entity; - this.setFullPanelsConfig(); - }); + this.record = await this.entityMapperService.load( + this.entityConstructor, + id, + ); } + this.initPanels(); + this.isLoading = false; } - private setInitialPanelsConfig() { - this.panels = this.config.panels.map((p) => ({ - title: p.title, - components: [], - })); - } - - private setFullPanelsConfig() { - this.panels = this.config.panels.map((p) => ({ + private initPanels() { + this.panelsComponents = this.panels.map((p) => ({ title: p.title, components: p.components.map((c) => ({ title: c.title, @@ -125,12 +127,11 @@ export class EntityDetailsComponent { config: this.getPanelConfig(c), })), })); - this.isLoading = false; } private getPanelConfig(c: PanelComponent): PanelConfig { let panelConfig: PanelConfig = { - entity: this.entity, + entity: this.record, creatingNew: this.creatingNew, }; if (typeof c.config === "object" && !Array.isArray(c.config)) { @@ -152,8 +153,8 @@ export class EntityDetailsComponent { trackTabChanged(index: number) { this.analyticsService.eventTrack("details_tab_changed", { - category: this.config?.entity, - label: this.config.panels[index].title, + category: this.entityType, + label: this.panels[index].title, }); } } diff --git a/src/app/core/entity-details/entity-details/entity-details.stories.ts b/src/app/core/entity-details/entity-details/entity-details.stories.ts index d11d74ba85..227e799ff1 100644 --- a/src/app/core/entity-details/entity-details/entity-details.stories.ts +++ b/src/app/core/entity-details/entity-details/entity-details.stories.ts @@ -3,8 +3,6 @@ import { StorybookBaseModule } from "../../../utils/storybook-base.module"; import { importProvidersFrom } from "@angular/core"; import { EntityDetailsComponent } from "./entity-details.component"; import { Child } from "../../../child-dev-project/children/model/child"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; import { EntityDetailsConfig } from "../EntityDetailsConfig"; const demoEntity = Child.create("John Doe"); @@ -41,14 +39,6 @@ export default { applicationConfig({ providers: [ importProvidersFrom(StorybookBaseModule.withData([demoEntity])), - { - provide: ActivatedRoute, - useValue: { - data: of({ config }), - paramMap: of(new Map([["id", demoEntity.getId(false)]])), - snapshot: { queryParamMap: { get: () => undefined } }, - }, - }, ], }), ], @@ -61,4 +51,7 @@ const Template: StoryFn = ( }); export const Primary = Template.bind({}); -Primary.args = {}; +Primary.args = { + id: demoEntity.getId(false), + ...config, +}; diff --git a/src/app/core/entity-list/entity-list/entity-list.component.ts b/src/app/core/entity-list/entity-list/entity-list.component.ts index c796b9a3a5..79eecfb93b 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.ts @@ -148,6 +148,7 @@ export class EntityListComponent private entities: EntityRegistry, private dialog: MatDialog, ) { + // TODO: refactor the EntityListComponent to make use of RoutedViewComponent and not load the route data itself if (this.activatedRoute.component === EntityListComponent) { // the component is used for a route and not inside a template this.activatedRoute.data.subscribe((data: RouteData) => diff --git a/src/app/core/ui/routed-view/routed-view.component.html b/src/app/core/ui/routed-view/routed-view.component.html new file mode 100644 index 0000000000..57632d64fc --- /dev/null +++ b/src/app/core/ui/routed-view/routed-view.component.html @@ -0,0 +1 @@ +

routed-view works!

diff --git a/src/app/core/ui/routed-view/routed-view.component.scss b/src/app/core/ui/routed-view/routed-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/ui/routed-view/routed-view.component.spec.ts b/src/app/core/ui/routed-view/routed-view.component.spec.ts new file mode 100644 index 0000000000..a4a9eab81d --- /dev/null +++ b/src/app/core/ui/routed-view/routed-view.component.spec.ts @@ -0,0 +1,76 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { RoutedViewComponent } from "./routed-view.component"; +import { ActivatedRoute } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; +import { ComponentRegistry } from "../../../dynamic-components"; +import { Component } from "@angular/core"; + +@Component({ + template: ``, +}) +class MockComponent {} + +describe("RoutedViewComponent", () => { + let component: RoutedViewComponent; + let fixture: ComponentFixture; + + let mockActivatedRoute; + + function mockParamMap(params: { [key: string]: any }) { + return { keys: Object.keys(params), get: (key: string) => params[key] }; + } + + beforeEach(() => { + mockActivatedRoute = { + data: new BehaviorSubject({ + component: "InitialComponent", + config: { testFlag: true }, + }), + paramMap: new BehaviorSubject(mockParamMap({})), + }; + + TestBed.configureTestingModule({ + imports: [RoutedViewComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: mockActivatedRoute as unknown as ActivatedRoute, + }, + { + provide: ComponentRegistry, + useValue: { get: () => async () => MockComponent }, + }, + ], + }); + fixture = TestBed.createComponent(RoutedViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should take component from route data and use it for dynamic component directive", fakeAsync(() => { + mockActivatedRoute.data.next({ component: "TestComponent" }); + tick(); + + expect(component.component).toEqual("TestComponent"); + })); + + it("should pass config route data on as config", fakeAsync(() => { + mockActivatedRoute.data.next({ config: { testDetail: "test" } }); + tick(); + + expect(component.config).toEqual({ testDetail: "test" }); + })); + + it("should add route param '/:id' to config", fakeAsync(() => { + mockActivatedRoute.paramMap.next(mockParamMap({ id: "123" })); + tick(); + + expect(component.config.id).toEqual("123"); + })); +}); diff --git a/src/app/core/ui/routed-view/routed-view.component.ts b/src/app/core/ui/routed-view/routed-view.component.ts new file mode 100644 index 0000000000..055faea027 --- /dev/null +++ b/src/app/core/ui/routed-view/routed-view.component.ts @@ -0,0 +1,47 @@ +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouteTarget } from "../../../app.routing"; +import { ActivatedRoute } from "@angular/router"; +import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; +import { ViewConfig } from "../../config/dynamic-routing/view-config.interface"; + +/** + * Wrapper component for a primary, full page view + * that takes parameters from the route and passes these on to normal @Input properties. + * + * This allows to develop functional feature components in a way to easily reuse them for display + * as a full page view or in a modal dialog. + */ +@RouteTarget("RoutedView") +@Component({ + selector: "app-routed-view", + standalone: true, + imports: [CommonModule, DynamicComponentDirective], + template: ``, +}) +export class RoutedViewComponent { + component: string; + config: T; + + constructor(route: ActivatedRoute) { + route.data.subscribe((data: { component: string } & ViewConfig) => { + this.component = data.component; + // pass all other config properties to the component as config + this.config = Object.assign({}, data.config); + + // merge updated config properties from route params + route.paramMap.subscribe((params) => { + const config = this.config; + for (const key of params.keys) { + config[key] = params.get(key); + } + this.config = { ...config }; + }); + + // TODO: should we also assigned queryParams from the url to input properties? + }); + } +} diff --git a/src/app/core/ui/ui-config.ts b/src/app/core/ui/ui-config.ts deleted file mode 100644 index 3ae7f1b1d3..0000000000 --- a/src/app/core/ui/ui-config.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Interface for the general configuration of the application. - * This is independent of the routes. - */ -export interface UiConfig { - /** - * The path to a logo icon inside the `assets` folder. - * This will be displayed on top of the navigation items. - */ - logo_path?: string; - - /** - * Toggle whether the language select component should be displayed. - * This should only be used if configurations for multiple languages are available. - */ - displayLanguageSelect?: boolean; - - /** - * The default language of the application which is used after login if the user doesn't select something else. - */ - default_language?: string; - - /** - * The title which is shown at the top of the application. - */ - site_name?: string; -} diff --git a/src/app/features/markdown-page/markdown-page/markdown-page.component.ts b/src/app/features/markdown-page/markdown-page/markdown-page.component.ts index 0e37e69ea0..aeba29b681 100644 --- a/src/app/features/markdown-page/markdown-page/markdown-page.component.ts +++ b/src/app/features/markdown-page/markdown-page/markdown-page.component.ts @@ -15,10 +15,7 @@ * along with ndb-core. If not, see . */ -import { Component, Input, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { MarkdownPageConfig } from "../MarkdownPageConfig"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { Component, Input } from "@angular/core"; import { RouteTarget } from "../../../app.routing"; import { MarkdownPageModule } from "../markdown-page.module"; @@ -32,16 +29,7 @@ import { MarkdownPageModule } from "../markdown-page.module"; imports: [MarkdownPageModule], standalone: true, }) -export class MarkdownPageComponent implements OnInit { +export class MarkdownPageComponent { /** filepath to be loaded as markdown */ @Input() markdownFile: string; - - constructor(private route: ActivatedRoute) {} - - ngOnInit() { - this.route.data.subscribe( - (data: RouteData) => - (this.markdownFile = data.config.markdownFile), - ); - } } diff --git a/src/app/features/reporting/reporting/reporting.component.html b/src/app/features/reporting/reporting/reporting.component.html index c9252b01fb..291c0d1941 100644 --- a/src/app/features/reporting/reporting/reporting.component.html +++ b/src/app/features/reporting/reporting/reporting.component.html @@ -9,7 +9,7 @@ ) => { - this.availableReports = data.config?.reports; - }, - ); - } - async calculateResults( selectedReport: ReportConfig, fromDate: Date, diff --git a/src/app/features/todos/todo-list/todo-list.component.ts b/src/app/features/todos/todo-list/todo-list.component.ts index f7c12d9202..06893502b3 100644 --- a/src/app/features/todos/todo-list/todo-list.component.ts +++ b/src/app/features/todos/todo-list/todo-list.component.ts @@ -46,6 +46,7 @@ export class TodoListComponent implements OnInit { ngOnInit() { this.route.data.subscribe((data: RouteData) => + // TODO replace this use of route and rely on the RoutedViewComponent instead this.init(data.config), ); } From ad8ef3a0a03152dc59d4af11aba6395b784582ba Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Sat, 28 Oct 2023 15:41:30 +0200 Subject: [PATCH 003/132] simplify and polish the basic entity admin ui --- .../config-entity-form.component.scss | 5 ++- .../config-entity-form.component.ts | 4 +-- .../config-entity/config-entity.component.ts | 17 +++++----- .../config-entity/config-entity.stories.ts | 34 +++++++++++++++++++ src/app/core/config-ui/config-ui.module.ts | 23 +++++++++++-- 5 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 src/app/core/config-ui/config-entity/config-entity.stories.ts diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss index c7ae86b660..b056b2aeee 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -5,7 +5,10 @@ background-color: colors.$grey-transparent; } .admin-form-field { - padding: sizes.$small; + padding: sizes.$small; + margin: sizes.$small; + border: dotted 1px colors.$accent; + border-radius: sizes.$x-small; } :host ::ng-deep input[disabled] { pointer-events: none } diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index 87fa2164eb..40e74229b4 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -54,8 +54,8 @@ export class ConfigEntityFormComponent implements OnChanges { openFieldConfig(field: FormFieldConfig) { this.matDialog.open(ConfigFieldComponent, { width: "99%", - height: "90vh", - data: { fieldConfig: field, entityType: this.entityType }, + maxHeight: "90vh", + data: { formFieldConfig: field, entityType: this.entityType }, }); } } diff --git a/src/app/core/config-ui/config-entity/config-entity.component.ts b/src/app/core/config-ui/config-entity/config-entity.component.ts index 6879d7e349..ebbe58b5be 100644 --- a/src/app/core/config-ui/config-entity/config-entity.component.ts +++ b/src/app/core/config-ui/config-entity/config-entity.component.ts @@ -1,5 +1,4 @@ -import { Component, Input } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { Component, Input, OnChanges } from "@angular/core"; import { EntityDetailsConfig } from "../../entity-details/EntityDetailsConfig"; import { EntityConstructor } from "../../entity/model/entity"; import { EntityRegistry } from "../../entity/database-entity.decorator"; @@ -8,27 +7,27 @@ import { PREFIX_VIEW_CONFIG, ViewConfig, } from "../../config/dynamic-routing/view-config.interface"; +import { DynamicComponent } from "../../config/dynamic-components/dynamic-component.decorator"; +@DynamicComponent("ConfigEntity") @Component({ selector: "app-config-entity", templateUrl: "./config-entity.component.html", styleUrls: ["./config-entity.component.scss"], }) -export class ConfigEntityComponent { +export class ConfigEntityComponent implements OnChanges { @Input() entityType: string; entityConstructor: EntityConstructor; configDetailsView: EntityDetailsConfig; constructor( - route: ActivatedRoute, private entities: EntityRegistry, private configService: ConfigService, - ) { - route.paramMap.subscribe((params) => { - this.entityType = params.get("entityType"); - this.init(); - }); + ) {} + + ngOnChanges(): void { + this.init(); } private init() { diff --git a/src/app/core/config-ui/config-entity/config-entity.stories.ts b/src/app/core/config-ui/config-entity/config-entity.stories.ts new file mode 100644 index 0000000000..1c245a8ff1 --- /dev/null +++ b/src/app/core/config-ui/config-entity/config-entity.stories.ts @@ -0,0 +1,34 @@ +import { + applicationConfig, + Meta, + moduleMetadata, + StoryFn, +} from "@storybook/angular"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { importProvidersFrom } from "@angular/core"; +import { ConfigEntityComponent } from "./config-entity.component"; +import { ConfigUiModule } from "../config-ui.module"; +import { FileModule } from "../../../features/file/file.module"; + +export default { + title: "Core/Admin UI/Config Entity", + component: ConfigEntityComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(StorybookBaseModule)], + }), + moduleMetadata({ + imports: [ConfigUiModule, FileModule], + }), + ], +} as Meta; + +const Template: StoryFn = (args) => ({ + component: ConfigEntityComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = { + entityType: "RecurringActivity", +}; diff --git a/src/app/core/config-ui/config-ui.module.ts b/src/app/core/config-ui/config-ui.module.ts index 89d0838ddb..9038e212d6 100644 --- a/src/app/core/config-ui/config-ui.module.ts +++ b/src/app/core/config-ui/config-ui.module.ts @@ -8,11 +8,16 @@ import { EntityTypeLabelPipe } from "../common-components/entity-type-label/enti import { ConfigEntityFormComponent } from "./config-entity-form/config-entity-form.component"; import { HelpButtonComponent } from "../common-components/help-button/help-button.component"; import { DynamicComponentDirective } from "../config/dynamic-components/dynamic-component.directive"; +import { RoutedViewComponent } from "../ui/routed-view/routed-view.component"; +import { ComponentRegistry } from "../../dynamic-components"; const routes: Routes = [ { path: "entity/:entityType", - component: ConfigEntityComponent, + component: RoutedViewComponent, + data: { + component: "ConfigEntity", + }, }, ]; @@ -30,7 +35,19 @@ const routes: Routes = [ HelpButtonComponent, DynamicComponentDirective, ], - exports: [RouterModule], + exports: [RouterModule, ConfigEntityComponent], declarations: [ConfigEntityComponent, ConfigEntityFormComponent], }) -export class ConfigUiModule {} +export class ConfigUiModule { + constructor(components: ComponentRegistry) { + components.addAll([ + [ + "ConfigEntity", + () => + import("./config-entity/config-entity.component").then( + (c) => c.ConfigEntityComponent, + ), + ], + ]); + } +} From 143463d7515a24bf764ae5686a60e0e32cab5df7 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Sat, 28 Oct 2023 22:16:23 +0200 Subject: [PATCH 004/132] avoid innerDataType --- .../attendance/model/event-attendance.ts | 2 +- .../attendance/model/recurring-activity.ts | 2 +- src/app/child-dev-project/children/aser/model/aser.ts | 8 ++++---- .../educational-material/model/educational-material.ts | 2 +- src/app/child-dev-project/children/model/child.ts | 4 ++-- src/app/child-dev-project/notes/model/note.ts | 4 ++-- src/app/core/config/config-fix.ts | 10 +++++----- src/app/core/site-settings/site-settings.ts | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/app/child-dev-project/attendance/model/event-attendance.ts b/src/app/child-dev-project/attendance/model/event-attendance.ts index 56b7ada164..022e16fb9c 100644 --- a/src/app/child-dev-project/attendance/model/event-attendance.ts +++ b/src/app/child-dev-project/attendance/model/event-attendance.ts @@ -13,7 +13,7 @@ export class EventAttendance { private _status: AttendanceStatusType; @DatabaseField({ dataType: "configurable-enum", - innerDataType: ATTENDANCE_STATUS_CONFIG_ID, + additional: ATTENDANCE_STATUS_CONFIG_ID, }) get status(): AttendanceStatusType { return this._status; diff --git a/src/app/child-dev-project/attendance/model/recurring-activity.ts b/src/app/child-dev-project/attendance/model/recurring-activity.ts index 06e2f1109f..0536f64887 100644 --- a/src/app/child-dev-project/attendance/model/recurring-activity.ts +++ b/src/app/child-dev-project/attendance/model/recurring-activity.ts @@ -67,7 +67,7 @@ export class RecurringActivity extends Entity { @DatabaseField({ label: $localize`:Label for the interaction type of a recurring activity:Type`, dataType: "configurable-enum", - innerDataType: INTERACTION_TYPE_CONFIG_ID, + additional: INTERACTION_TYPE_CONFIG_ID, anonymize: "retain", }) type: InteractionType; diff --git a/src/app/child-dev-project/children/aser/model/aser.ts b/src/app/child-dev-project/children/aser/model/aser.ts index 045bf2b32d..6cd57c65ef 100644 --- a/src/app/child-dev-project/children/aser/model/aser.ts +++ b/src/app/child-dev-project/children/aser/model/aser.ts @@ -34,25 +34,25 @@ export class Aser extends Entity { @DatabaseField({ label: $localize`:Label of the Hindi ASER result:Hindi`, dataType: "configurable-enum", - innerDataType: "reading-levels", + additional: "reading-levels", }) hindi: SkillLevel; @DatabaseField({ label: $localize`:Label of the Bengali ASER result:Bengali`, dataType: "configurable-enum", - innerDataType: "reading-levels", + additional: "reading-levels", }) bengali: SkillLevel; @DatabaseField({ label: $localize`:Label of the English ASER result:English`, dataType: "configurable-enum", - innerDataType: "reading-levels", + additional: "reading-levels", }) english: SkillLevel; @DatabaseField({ label: $localize`:Label of the Math ASER result:Math`, dataType: "configurable-enum", - innerDataType: "math-levels", + additional: "math-levels", }) math: SkillLevel; @DatabaseField({ diff --git a/src/app/child-dev-project/children/educational-material/model/educational-material.ts b/src/app/child-dev-project/children/educational-material/model/educational-material.ts index 66d0991cfb..ffdc8e7510 100644 --- a/src/app/child-dev-project/children/educational-material/model/educational-material.ts +++ b/src/app/child-dev-project/children/educational-material/model/educational-material.ts @@ -34,7 +34,7 @@ export class EducationalMaterial extends Entity { @DatabaseField({ label: $localize`:The material which has been borrowed:Material`, dataType: "configurable-enum", - innerDataType: "materials", + additional: "materials", validators: { required: true, }, diff --git a/src/app/child-dev-project/children/model/child.ts b/src/app/child-dev-project/children/model/child.ts index 5a955b252a..f49215a2e8 100644 --- a/src/app/child-dev-project/children/model/child.ts +++ b/src/app/child-dev-project/children/model/child.ts @@ -68,14 +68,14 @@ export class Child extends Entity { @DatabaseField({ dataType: "configurable-enum", label: $localize`:Label for the gender of a child:Gender`, - innerDataType: "genders", + additional: "genders", anonymize: "retain", }) gender: ConfigurableEnumValue; @DatabaseField({ dataType: "configurable-enum", - innerDataType: "center", + additional: "center", label: $localize`:Label for the center of a child:Center`, anonymize: "retain", }) diff --git a/src/app/child-dev-project/notes/model/note.ts b/src/app/child-dev-project/notes/model/note.ts index 68a08a43df..3db337688c 100644 --- a/src/app/child-dev-project/notes/model/note.ts +++ b/src/app/child-dev-project/notes/model/note.ts @@ -123,7 +123,7 @@ export class Note extends Entity { @DatabaseField({ label: $localize`:Label for the category of a note:Category`, dataType: "configurable-enum", - innerDataType: INTERACTION_TYPE_CONFIG_ID, + additional: INTERACTION_TYPE_CONFIG_ID, anonymize: "retain", }) category: InteractionType; @@ -166,7 +166,7 @@ export class Note extends Entity { @DatabaseField({ label: $localize`:Status of a note:Status`, dataType: "configurable-enum", - innerDataType: "warning-levels", + additional: "warning-levels", anonymize: "retain", }) warningLevel: Ordering.EnumValue; diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index f6757251dc..5322f240f2 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -1043,7 +1043,7 @@ export const defaultJsonConfig = { "name": "isMotivatedDuringClass", "schema": { "dataType": "configurable-enum", - "innerDataType": "rating-answer", + "additional": "rating-answer", "label": $localize`:Label for a child attribute:Motivated`, description: $localize`:Description for a child attribute:The child is motivated during the class.` } @@ -1052,7 +1052,7 @@ export const defaultJsonConfig = { "name": "isParticipatingInClass", "schema": { "dataType": "configurable-enum", - "innerDataType": "rating-answer", + "additional": "rating-answer", "label": $localize`:Label for a child attribute:Participating`, description: $localize`:Description for a child attribute:The child is actively participating in the class.` } @@ -1061,7 +1061,7 @@ export const defaultJsonConfig = { "name": "isInteractingWithOthers", "schema": { "dataType": "configurable-enum", - "innerDataType": "rating-answer", + "additional": "rating-answer", "label": $localize`:Label for a child attribute:Interacting`, description: $localize`:Description for a child attribute:The child interacts with other students during the class.` } @@ -1070,7 +1070,7 @@ export const defaultJsonConfig = { "name": "doesHomework", "schema": { "dataType": "configurable-enum", - "innerDataType": "rating-answer", + "additional": "rating-answer", "label": $localize`:Label for a child attribute:Homework`, description: $localize`:Description for a child attribute:The child does its homework.` } @@ -1079,7 +1079,7 @@ export const defaultJsonConfig = { "name": "asksQuestions", "schema": { "dataType": "configurable-enum", - "innerDataType": "rating-answer", + "additional": "rating-answer", "label": $localize`:Label for a child attribute:Asking Questions`, description: $localize`:Description for a child attribute:The child is asking questions during the class.` } diff --git a/src/app/core/site-settings/site-settings.ts b/src/app/core/site-settings/site-settings.ts index d5f2fc51a9..f328eb8aa7 100644 --- a/src/app/core/site-settings/site-settings.ts +++ b/src/app/core/site-settings/site-settings.ts @@ -24,7 +24,7 @@ export class SiteSettings extends Entity { label: $localize`Default language`, description: $localize`This will only be applied once the app is reloaded`, dataType: "configurable-enum", - innerDataType: LOCALE_ENUM_ID, + additional: LOCALE_ENUM_ID, }) defaultLanguage: ConfigurableEnumValue = availableLocales.values.find( ({ id }) => id === "en-US", From eda4778a33b577d8ea90909be6fb36e63b2b1440 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Sat, 28 Oct 2023 22:16:43 +0200 Subject: [PATCH 005/132] basic field config admin ui --- .../configurable-enum.service.ts | 4 + .../entity-form/entity-form.service.ts | 4 +- .../config-field/config-field.component.html | 125 +++++++++++- .../config-field/config-field.component.scss | 8 + .../config-field/config-field.component.ts | 180 +++++++++++++++++- .../config-field/config-field.stories.ts | 53 ++++++ 6 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 src/app/core/config-ui/config-field/config-field.stories.ts diff --git a/src/app/core/basic-datatypes/configurable-enum/configurable-enum.service.ts b/src/app/core/basic-datatypes/configurable-enum/configurable-enum.service.ts index 0d117ea76c..169ff72cbb 100644 --- a/src/app/core/basic-datatypes/configurable-enum/configurable-enum.service.ts +++ b/src/app/core/basic-datatypes/configurable-enum/configurable-enum.service.ts @@ -47,4 +47,8 @@ export class ConfigurableEnumService { } return this.enums.get(entityId); } + + listEnums() { + return Array.from(this.enums.keys()); + } } diff --git a/src/app/core/common-components/entity-form/entity-form.service.ts b/src/app/core/common-components/entity-form/entity-form.service.ts index 06e4c1e45e..6484537059 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.ts @@ -69,7 +69,7 @@ export class EntityFormService { forTable = false, ): FormFieldConfig[] { const fullFields: FormFieldConfig[] = formFields.map(toFormFieldConfig); - fullFields.forEach((formField) => { + for (const formField of fullFields) { try { this.addFormFields(formField, entityType, forTable); } catch (err) { @@ -77,7 +77,7 @@ export class EntityFormService { `Could not create form config for ${formField.id}: ${err}`, ); } - }); + } return fullFields; } diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html index d469ede484..23f32eec59 100644 --- a/src/app/core/config-ui/config-field/config-field.component.html +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -1 +1,124 @@ -

config-field works!

+

Configure Field "{{ schemaFieldConfig.label }}"

+ + + +

+ The field settings here apply to the record type overall and affect both the + field here in the current view as well as all other forms and lists where + this field is displayed. +

+ +
+ + +
+
+ + Label + + + + + Label (short) + + + + + + + Description + + + +
+ +
+ + + Field ID (readonly) + + + + + + + + Type + + + + + Type Details + + +
+
+
+ + + +
+
+ + Default Value + + + + + Anonymize + + + + + Searchable + + +
+ +
+ + Field Validation + + +
+
+
+
+
+
+ + + + diff --git a/src/app/core/config-ui/config-field/config-field.component.scss b/src/app/core/config-ui/config-field/config-field.component.scss index e69de29bb2..0280040447 100644 --- a/src/app/core/config-ui/config-field/config-field.component.scss +++ b/src/app/core/config-ui/config-field/config-field.component.scss @@ -0,0 +1,8 @@ +@use "src/styles/mixins/grid-layout"; + +.grid-layout { + @include grid-layout.adaptive( + $min-block-width: 250px, + $max-screen-width: 414px + ); +} diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index 5049e3cd89..e38d238c20 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -1,4 +1,33 @@ -import { Component } from "@angular/core"; +import { Component, Inject, Input } from "@angular/core"; +import { Entity, EntityConstructor } from "../../entity/model/entity"; +import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; +import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog"; +import { MatButtonModule } from "@angular/material/button"; +import { DialogCloseComponent } from "../../common-components/dialog-close/dialog-close.component"; +import { MatInputModule } from "@angular/material/input"; +import { ErrorHintComponent } from "../../common-components/error-hint/error-hint.component"; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from "@angular/forms"; +import { NgIf } from "@angular/common"; +import { EntitySchemaField } from "../../entity/schema/entity-schema-field"; +import { MatTabsModule } from "@angular/material/tabs"; +import { MatSlideToggleModule } from "@angular/material/slide-toggle"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { BasicAutocompleteComponent } from "../../common-components/basic-autocomplete/basic-autocomplete.component"; +import { DefaultDatatype } from "../../entity/default-datatype/default.datatype"; +import { ConfigurableEnumDatatype } from "../../basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype"; +import { EntityDatatype } from "../../basic-datatypes/entity/entity.datatype"; +import { EntityArrayDatatype } from "../../basic-datatypes/entity-array/entity-array.datatype"; +import { ConfigurableEnumService } from "../../basic-datatypes/configurable-enum/configurable-enum.service"; +import { ArrayDatatype } from "../../basic-datatypes/array/array.datatype"; +import { SchemaEmbedDatatype } from "../../basic-datatypes/schema-embed/schema-embed.datatype"; +import { MapDatatype } from "../../basic-datatypes/map/map.datatype"; /** * Allows configuration of the schema of a single Entity field, like its dataType and labels. @@ -6,7 +35,152 @@ import { Component } from "@angular/core"; @Component({ selector: "app-config-field", templateUrl: "./config-field.component.html", - styleUrls: ["./config-field.component.scss"], + styleUrls: [ + "./config-field.component.scss", + "../../common-components/entity-form/entity-form/entity-form.component.scss", + ], standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + DialogCloseComponent, + MatInputModule, + ErrorHintComponent, + FormsModule, + NgIf, + MatTabsModule, + MatSlideToggleModule, + ReactiveFormsModule, + FontAwesomeModule, + MatTooltipModule, + BasicAutocompleteComponent, + ], }) -export class ConfigFieldComponent {} +export class ConfigFieldComponent { + @Input() entityType: EntityConstructor; + @Input() formFieldConfig: FormFieldConfig; + field: string; + + schemaFieldConfig: EntitySchemaField & { id?: string }; // TODO: add id / key to EntitySchemaField for easier handling? + + form: FormGroup; + formLabelShort: FormControl; + useShortLabel: boolean; + formAdditional: FormControl; + formAdditionalOptions: any[] = null; + dataTypes = []; + + constructor( + @Inject(MAT_DIALOG_DATA) + data: { + entityType: EntityConstructor; + formFieldConfig: FormFieldConfig; + }, + private fb: FormBuilder, + @Inject(DefaultDatatype) dataTypes: DefaultDatatype[], + private configurableEnumService: ConfigurableEnumService, + ) { + this.entityType = data.entityType; + this.formFieldConfig = data.formFieldConfig; + // TODO: merge formField and schemaField config interfaces to be exactly matching, simply enabling direct overwrites? + this.schemaFieldConfig = { + ...this.entityType.schema.get(this.formFieldConfig.id), + id: this.formFieldConfig.id, + }; + + this.initSettings(); + this.initAvailableDatatypes(dataTypes); + } + + private initSettings() { + this.formLabelShort = this.fb.control(this.schemaFieldConfig.labelShort); + this.formAdditional = this.fb.control(this.schemaFieldConfig.additional); + + this.form = this.fb.group({ + label: [this.schemaFieldConfig.label], + labelShort: this.formLabelShort, + description: [this.schemaFieldConfig.description], + + id: this.fb.control({ value: this.schemaFieldConfig.id, disabled: true }), + dataType: [this.schemaFieldConfig.dataType], + additional: [this.schemaFieldConfig.additional], + + // TODO: remove "innerDataType" completely - the UI can only support very specific multi-valued types anyway + // TODO add a datatype "alias" for enum-array + innerDataType: [this.schemaFieldConfig.innerDataType], + + defaultValue: [this.schemaFieldConfig.defaultValue], + searchable: [this.schemaFieldConfig.searchable], + anonymize: [this.schemaFieldConfig.anonymize], + //viewComponent: [], + //editComponent: [], + //showInDetailsView: [], + //generateIndex: [], + validators: [this.schemaFieldConfig.validators], + }); + + this.updateShortLabelToggle(!!this.formLabelShort.value); + this.updateDataTypeAdditional(this.form.get("dataType").value); + this.form + .get("dataType") + .valueChanges.subscribe((v) => this.updateDataTypeAdditional(v)); + } + + updateShortLabelToggle(useShortLabel: boolean) { + this.useShortLabel = useShortLabel; + + if (!this.useShortLabel) { + this.formLabelShort.setValue(null); + this.formLabelShort.disable(); + } + + if ( + this.useShortLabel && + this.formLabelShort.disabled && + this.form.enabled + ) { + this.formLabelShort.setValue(this.form.get("label").value); + this.formLabelShort.enable(); + } + } + + private initAvailableDatatypes(dataTypes: DefaultDatatype[]) { + this.dataTypes = dataTypes + .filter( + (d) => + d.dataType !== ArrayDatatype.dataType && + d.dataType !== SchemaEmbedDatatype.dataType && + d.dataType !== MapDatatype.dataType, + ) + .map((d) => ({ + label: d.dataType, + value: d.dataType, + })); + // TODO: human-readable names for data types + } + objectToLabel = (v: { label: string }) => v?.label; + objectToValue = (v: { value: string }) => v?.value; + + private updateDataTypeAdditional(dataType: string) { + if (dataType === ConfigurableEnumDatatype.dataType) { + this.formAdditionalOptions = this.configurableEnumService + .listEnums() + .map((x) => Entity.extractEntityIdFromId(x)); + // TODO allow new enum creation + // TODO preview the options within the selected enum (and allow to edit the enum options?) + } else if ( + dataType === EntityDatatype.dataType || + dataType === EntityArrayDatatype.dataType + ) { + // TODO reuse and generalize ImportEntityTypeComponent.loadEntityTypes() + this.formAdditionalOptions = []; + } else { + this.form.get("additional").setValue(null); + this.formAdditionalOptions = null; + } + + // hasInnerType: [ArrayDatatype.dataType].includes(d.dataType), + + // TODO: this mapping of having an "additional" schema should probably become part of Datatype classes + } +} diff --git a/src/app/core/config-ui/config-field/config-field.stories.ts b/src/app/core/config-ui/config-field/config-field.stories.ts new file mode 100644 index 0000000000..090c116098 --- /dev/null +++ b/src/app/core/config-ui/config-field/config-field.stories.ts @@ -0,0 +1,53 @@ +import { + applicationConfig, + Meta, + moduleMetadata, + StoryFn, +} from "@storybook/angular"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { importProvidersFrom } from "@angular/core"; +import { ConfigFieldComponent } from "./config-field.component"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; + +const sampleFieldConfig: FormFieldConfig = { + id: "name", + edit: "EditText", + view: "DisplayText", + forTable: false, + label: "Name", + validators: { + required: true, + }, +}; + +export default { + title: "Core/Admin UI/Config Field", + component: ConfigFieldComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(StorybookBaseModule)], + }), + moduleMetadata({ + imports: [ConfigFieldComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + entityType: Child, + formFieldConfig: sampleFieldConfig, + }, + }, + ], + }), + ], +} as Meta; + +const Template: StoryFn = (args) => ({ + component: ConfigFieldComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = {}; From e9e40e32090b1f88a03188b834118a4ef948c7d9 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Sun, 29 Oct 2023 00:11:42 +0200 Subject: [PATCH 006/132] basic drag & drop form admin ui --- .../config-entity-form.component.html | 94 ++++++++++++++----- .../config-entity-form.component.scss | 52 ++++++++++ .../config-entity-form.component.ts | 56 +++++++++-- src/app/core/config-ui/config-ui.module.ts | 20 ++++ 4 files changed, 188 insertions(+), 34 deletions(-) diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index 71640219d4..d6f0001b90 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -1,35 +1,79 @@ -
+
-

- {{ fullConfig.columnHeaders[i] }} -

+ +
+ + Group Title + + + + +
-
-
- - + +
+ + +
+ + +
+
- + +
+
+ + +
+
+ drop here to create new field group
-
diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss index b056b2aeee..d8ba6b5ce7 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -11,4 +11,56 @@ border-radius: sizes.$x-small; } +.drag-handle { + color: colors.$accent; + cursor: move; + min-width: 2em; + text-align: center; +} + :host ::ng-deep input[disabled] { pointer-events: none } + + +.fields-group-list { + border: dashed 1px #ccc; + min-height: 60px; + height: 100%; + border-radius: 4px; + overflow: hidden; + display: block; +} + +.drop-create-area { + text-align: center; + padding: sizes.$small; + color: colors.$hint-text +} +.admin-form-column:has(.group-remove-button:hover) { + border-color: rgb(255, 0, 0); + background-color: rgba(255, 0, 0, 0.1); +} + +.admin-form-column { + border: dashed 1px #ccc; + padding: sizes.$small; +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.fields-group-list.cdk-drop-list-dragging .admin-form-field:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index 40e74229b4..7367368790 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -1,5 +1,4 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { EntityFormConfig } from "../../common-components/entity-form/entity-form/entity-form.component"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { FormConfig } from "../../entity-details/form/form.component"; import { EntityFormService } from "../../common-components/entity-form/entity-form.service"; @@ -7,6 +6,11 @@ import { FormGroup } from "@angular/forms"; import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; import { MatDialog } from "@angular/material/dialog"; import { ConfigFieldComponent } from "../config-field/config-field.component"; +import { + CdkDragDrop, + moveItemInArray, + transferArrayItem, +} from "@angular/cdk/drag-drop"; @Component({ selector: "app-config-entity-form", @@ -19,7 +23,7 @@ import { ConfigFieldComponent } from "../config-field/config-field.component"; export class ConfigEntityFormComponent implements OnChanges { @Input() entityType: EntityConstructor; @Input() config: FormConfig; - fullConfig: EntityFormConfig; + fieldGroups: { header?: string; fields: FormFieldConfig[] }[]; // TODO: maybe change the config itself to reflect this structure? dummyEntity: Entity; dummyForm: FormGroup; @@ -36,16 +40,20 @@ export class ConfigEntityFormComponent implements OnChanges { } private prepareConfig(config: FormConfig) { - this.fullConfig = { - columns: config.cols.map((fields) => - this.entityFormService.extendFormFieldConfig(fields, this.entityType), - ), - columnHeaders: config.headers, - }; + this.fieldGroups = []; + for (let i = 0; i < config.cols.length; i++) { + this.fieldGroups.push({ + header: config.headers?.[i], + fields: this.entityFormService.extendFormFieldConfig( + config.cols[i], + this.entityType, + ), + }); + } this.dummyEntity = new this.entityType(); this.dummyForm = this.entityFormService.createFormGroup( - [].concat(...this.fullConfig.columns), + this.fieldGroups.reduce((p, c) => p.concat(c.fields), []), this.dummyEntity, ); this.dummyForm.disable(); @@ -58,4 +66,34 @@ export class ConfigEntityFormComponent implements OnChanges { data: { formFieldConfig: field, entityType: this.entityType }, }); } + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray( + event.container.data, + event.previousIndex, + event.currentIndex, + ); + } else { + transferArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex, + ); + } + + console.log(this.fieldGroups); + } + + dropNewGroup(event: CdkDragDrop) { + const newCol = { fields: [] }; + this.fieldGroups.push(newCol); + event.container.data = newCol.fields; + this.drop(event); + } + + removeGroup(i: number) { + this.fieldGroups.splice(i, 1); + } } diff --git a/src/app/core/config-ui/config-ui.module.ts b/src/app/core/config-ui/config-ui.module.ts index 9038e212d6..1a655d880a 100644 --- a/src/app/core/config-ui/config-ui.module.ts +++ b/src/app/core/config-ui/config-ui.module.ts @@ -10,6 +10,17 @@ import { HelpButtonComponent } from "../common-components/help-button/help-butto import { DynamicComponentDirective } from "../config/dynamic-components/dynamic-component.directive"; import { RoutedViewComponent } from "../ui/routed-view/routed-view.component"; import { ComponentRegistry } from "../../dynamic-components"; +import { + CdkDrag, + CdkDragHandle, + CdkDropList, + CdkDropListGroup, +} from "@angular/cdk/drag-drop"; +import { FormsModule } from "@angular/forms"; +import { MatInputModule } from "@angular/material/input"; +import { MatButtonModule } from "@angular/material/button"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { MatTooltipModule } from "@angular/material/tooltip"; const routes: Routes = [ { @@ -34,6 +45,15 @@ const routes: Routes = [ MatTabsModule, HelpButtonComponent, DynamicComponentDirective, + CdkDrag, + CdkDropList, + CdkDropListGroup, + FormsModule, + MatInputModule, + MatButtonModule, + FontAwesomeModule, + MatTooltipModule, + CdkDragHandle, ], exports: [RouterModule, ConfigEntityComponent], declarations: [ConfigEntityComponent, ConfigEntityFormComponent], From d1cb4e958994807c4bcd2c329f5b018c7151106c Mon Sep 17 00:00:00 2001 From: Brajesh Kumar <78679532+brajesh-lab@users.noreply.github.com> Date: Tue, 31 Oct 2023 14:37:34 +0530 Subject: [PATCH 007/132] feat(export): export only human-readable fields and use better headers (#2033) closes #1918 --------- Co-authored-by: Sebastian Leidig --- .../download-service/download.service.spec.ts | 66 +++++++++++++++++-- .../download-service/download.service.ts | 58 ++++++++++++++-- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/app/core/export/download-service/download.service.spec.ts b/src/app/core/export/download-service/download.service.spec.ts index 9ae04ea0ce..c45574a155 100644 --- a/src/app/core/export/download-service/download.service.spec.ts +++ b/src/app/core/export/download-service/download.service.spec.ts @@ -49,6 +49,7 @@ describe("DownloadService", () => { // reset createElement otherwise results in: 'an Error was thrown after all' document.createElement = oldCreateElement; }); + it("should contain a column for every property", async () => { const docs = [ { _id: "Test:1", test: 1 }, @@ -77,11 +78,12 @@ describe("DownloadService", () => { '"_id","_rev","propOne","propTwo"' + DownloadService.SEPARATOR_ROW + '"TestForCsvEntity:1","2","first","second"'; + spyOn(service, "exportFile").and.returnValue(expected); const result = await service.createCsv([test]); expect(result).toEqual(expected); }); - it("should transform object properties to their label for export", async () => { + it("should transform object values to their label for export when available (e.g. configurable-enum)", async () => { const testEnumValue: ConfigurableEnumValue = { id: "ID VALUE", label: "label value", @@ -90,9 +92,10 @@ describe("DownloadService", () => { @DatabaseEntity("TestEntity") class TestEntity extends Entity { - @DatabaseField() enumProperty: ConfigurableEnumValue; - @DatabaseField() dateProperty: Date; - @DatabaseField() boolProperty: boolean; + @DatabaseField({ label: "test enum" }) + enumProperty: ConfigurableEnumValue; + @DatabaseField({ label: "test date" }) dateProperty: Date; + @DatabaseField({ label: "test boolean" }) boolProperty: boolean; } const testEntity = new TestEntity(); @@ -105,12 +108,65 @@ describe("DownloadService", () => { const rows = csvExport.split(DownloadService.SEPARATOR_ROW); expect(rows).toHaveSize(1 + 1); // includes 1 header line const columnValues = rows[1].split(DownloadService.SEPARATOR_COL); - expect(columnValues).toHaveSize(3 + 1); // Properties + _id + expect(columnValues).toHaveSize(3); // Properties (_id is filter out by default) expect(columnValues).toContain('"' + testEnumValue.label + '"'); expect(columnValues).toContain('"' + testDate + '"'); expect(columnValues).toContain('"true"'); }); + it("should export all properties using object keys as headers, if no schema is available", async () => { + const docs = [ + { _id: "Test:1", name: "Child 1" }, + { _id: "Test:2", name: "Child 2" }, + ]; + + const csvExport = await service.createCsv(docs); + + const rows = csvExport.split(DownloadService.SEPARATOR_ROW); + expect(rows).toHaveSize(2 + 1); // includes 1 header line + const columnHeaders = rows[0].split(DownloadService.SEPARATOR_COL); + const columnValues = rows[1].split(DownloadService.SEPARATOR_COL); + + expect(columnValues).toHaveSize(2); + expect(columnHeaders).toHaveSize(2); + expect(columnHeaders).toContain('"_id"'); + }); + + it("should only export columns that have labels defined in entity schema and use the schema labels as export headers", async () => { + const testString: string = "Test 1"; + + @DatabaseEntity("LabelTestEntity") + class LabelTestEntity extends Entity { + @DatabaseField({ label: "test string" }) stringProperty: string; + @DatabaseField({ label: "test date" }) otherProperty: string; + @DatabaseField() boolProperty: boolean; + } + + const labelTestEntity = new LabelTestEntity(); + labelTestEntity.stringProperty = testString; + labelTestEntity.otherProperty = "x"; + labelTestEntity.boolProperty = true; + + const incompleteTestEntity = new LabelTestEntity(); + incompleteTestEntity.otherProperty = "second row"; + + const csvExport = await service.createCsv([ + labelTestEntity, + incompleteTestEntity, + ]); + + const rows = csvExport.split(DownloadService.SEPARATOR_ROW); + expect(rows).toHaveSize(1 + 2); // includes 1 header line + + const columnHeaders = rows[0].split(DownloadService.SEPARATOR_COL); + expect(columnHeaders).toHaveSize(2); + expect(columnHeaders).toContain('"test string"'); + expect(columnHeaders).toContain('"test date"'); + + const entity2Values = rows.find((r) => r.includes("second row")); + expect(entity2Values).toEqual(',"second row"'); // first column empty! + }); + it("should export a date as YYYY-MM-dd only", async () => { const dateString = "2021-01-01"; const dateObject = moment(dateString).toDate(); diff --git a/src/app/core/export/download-service/download.service.ts b/src/app/core/export/download-service/download.service.ts index 147df776db..57fe6180fc 100644 --- a/src/app/core/export/download-service/download.service.ts +++ b/src/app/core/export/download-service/download.service.ts @@ -5,6 +5,7 @@ import { LoggingService } from "../../logging/logging.service"; import { DataTransformationService } from "../data-transformation-service/data-transformation.service"; import { transformToReadableFormat } from "../../common-components/entity-subrecord/entity-subrecord/value-accessor"; import { Papa } from "ngx-papaparse"; +import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field"; /** * This service allows to start a download process from the browser. @@ -90,17 +91,62 @@ export class DownloadService { * @returns string a valid CSV string of the input data */ async createCsv(data: any[]): Promise { - // Collect all properties because papa only uses the properties of the first object + let entityConstructor: any; + + if (data.length > 0 && typeof data[0]?.getConstructor === "function") { + entityConstructor = data[0].getConstructor(); + } const keys = new Set(); data.forEach((row) => Object.keys(row).forEach((key) => keys.add(key))); data = data.map(transformToReadableFormat); - return this.papa.unparse(data, { - quotes: true, - header: true, - newline: DownloadService.SEPARATOR_ROW, - columns: [...keys], + if (!entityConstructor) { + return this.papa.unparse(data, { + quotes: true, + header: true, + newline: DownloadService.SEPARATOR_ROW, + columns: [...keys], + }); + } + + const result = this.exportFile(data, entityConstructor); + return result; + } + + exportFile(data: any[], entityConstructor: { schema: any }) { + const entitySchema = entityConstructor.schema; + const columnLabels = new Map(); + + entitySchema.forEach((value: { label: EntitySchemaField }, key: string) => { + if (value.label) columnLabels.set(key, value.label); }); + + const exportEntities = data.map((item) => { + let newItem = {}; + for (const key in item) { + if (columnLabels.has(key)) { + newItem[key] = item[key]; + } + } + return newItem; + }); + + const columnKeys: string[] = Array.from(columnLabels.keys()); + const labels: any[] = Array.from(columnLabels.values()); + const orderedData: any[] = exportEntities.map((item) => + columnKeys.map((key) => item[key]), + ); + + return this.papa.unparse( + { + fields: labels, + data: orderedData, + }, + { + quotes: true, + newline: DownloadService.SEPARATOR_ROW, + }, + ); } } From 47de3e796aa0ad118f6182d612c18062fc6bc59b Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Fri, 3 Nov 2023 16:55:48 +0100 Subject: [PATCH 008/132] basic create new field + hide fields --- .../config-entity-form.component.html | 149 +++++++++++------- .../config-entity-form.component.scss | 17 +- .../config-entity-form.component.ts | 13 +- src/app/core/config-ui/config-ui.module.ts | 2 + 4 files changed, 115 insertions(+), 66 deletions(-) diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index d6f0001b90..9e22ac993f 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -1,43 +1,102 @@ -
- -
- -
- - Group Title - - + + + +
+ +
+ + Group Title + + + + +
- + +
+ + +
+ + +
+ +
+ +
+ +
+
+ drop here to create new field group +
+
+ + + +
- +
+ hidden fields
+ drag & drop to / from here +
+
-
- - -
- +
{{ f.label }}
- -
-
- - -
-
- drop here to create new field group
-
-
+ + diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss index d8ba6b5ce7..d400b04aef 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -20,17 +20,23 @@ :host ::ng-deep input[disabled] { pointer-events: none } +.toolbar { + width: 300px; + padding: sizes.$small; +} +.drop-list { + min-height: 60px; + height: 99%; +} .fields-group-list { border: dashed 1px #ccc; - min-height: 60px; - height: 100%; border-radius: 4px; overflow: hidden; display: block; } -.drop-create-area { +.drop-area-hint { text-align: center; padding: sizes.$small; color: colors.$hint-text @@ -54,13 +60,14 @@ } .cdk-drag-placeholder { - opacity: 0; + opacity: 0.4; + border-color: green; } .cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } -.fields-group-list.cdk-drop-list-dragging .admin-form-field:not(.cdk-drag-placeholder) { +.drop-list.cdk-drop-list-dragging .admin-form-field:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index 7367368790..17b0f93e3e 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -27,6 +27,7 @@ export class ConfigEntityFormComponent implements OnChanges { dummyEntity: Entity; dummyForm: FormGroup; + availableFields: any[] = [{ id: null, label: "Create New Field" }]; constructor( private entityFormService: EntityFormService, @@ -59,7 +60,7 @@ export class ConfigEntityFormComponent implements OnChanges { this.dummyForm.disable(); } - openFieldConfig(field: FormFieldConfig) { + openFieldConfig(field: FormFieldConfig | {}) { this.matDialog.open(ConfigFieldComponent, { width: "99%", maxHeight: "90vh", @@ -68,6 +69,14 @@ export class ConfigEntityFormComponent implements OnChanges { } drop(event: CdkDragDrop) { + const item = event.previousContainer.data[event.previousIndex]; + if (item.id === null) { + const newField = {}; + event.container.data.splice(event.currentIndex, 0, newField); + this.openFieldConfig(newField); + return; + } + if (event.previousContainer === event.container) { moveItemInArray( event.container.data, @@ -82,8 +91,6 @@ export class ConfigEntityFormComponent implements OnChanges { event.currentIndex, ); } - - console.log(this.fieldGroups); } dropNewGroup(event: CdkDragDrop) { diff --git a/src/app/core/config-ui/config-ui.module.ts b/src/app/core/config-ui/config-ui.module.ts index 1a655d880a..a9e57f2439 100644 --- a/src/app/core/config-ui/config-ui.module.ts +++ b/src/app/core/config-ui/config-ui.module.ts @@ -21,6 +21,7 @@ import { MatInputModule } from "@angular/material/input"; import { MatButtonModule } from "@angular/material/button"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { MatSidenavModule } from "@angular/material/sidenav"; const routes: Routes = [ { @@ -54,6 +55,7 @@ const routes: Routes = [ FontAwesomeModule, MatTooltipModule, CdkDragHandle, + MatSidenavModule, ], exports: [RouterModule, ConfigEntityComponent], declarations: [ConfigEntityComponent, ConfigEntityFormComponent], From f97a63191333e88a3a823b8e8fa02bb014f8111b Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Mon, 6 Nov 2023 15:53:55 +0100 Subject: [PATCH 009/132] options to select entity-type as additional --- .../config-field/config-field.component.html | 4 +++- .../config-field/config-field.component.ts | 16 +++++++++++----- .../core/entity/database-entity.decorator.ts | 19 ++++++++++++++++++- .../import-entity-type.component.ts | 14 ++------------ .../import-entity-type.stories.ts | 3 ++- src/app/utils/storybook-base.module.ts | 8 +++++++- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html index 23f32eec59..fce07bba9c 100644 --- a/src/app/core/config-ui/config-field/config-field.component.html +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -74,7 +74,9 @@

Configure Field "{{ schemaFieldConfig.label }}"

Type Details
diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index e38d238c20..50bbae33ec 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -28,6 +28,7 @@ import { ConfigurableEnumService } from "../../basic-datatypes/configurable-enum import { ArrayDatatype } from "../../basic-datatypes/array/array.datatype"; import { SchemaEmbedDatatype } from "../../basic-datatypes/schema-embed/schema-embed.datatype"; import { MapDatatype } from "../../basic-datatypes/map/map.datatype"; +import { EntityRegistry } from "../../entity/database-entity.decorator"; /** * Allows configuration of the schema of a single Entity field, like its dataType and labels. @@ -67,8 +68,8 @@ export class ConfigFieldComponent { formLabelShort: FormControl; useShortLabel: boolean; formAdditional: FormControl; - formAdditionalOptions: any[] = null; - dataTypes = []; + formAdditionalOptions: { label: string; value: any }[] = null; + dataTypes: { label: string; value: any }[] = []; constructor( @Inject(MAT_DIALOG_DATA) @@ -79,6 +80,7 @@ export class ConfigFieldComponent { private fb: FormBuilder, @Inject(DefaultDatatype) dataTypes: DefaultDatatype[], private configurableEnumService: ConfigurableEnumService, + private entityRegistry: EntityRegistry, ) { this.entityType = data.entityType; this.formFieldConfig = data.formFieldConfig; @@ -165,15 +167,19 @@ export class ConfigFieldComponent { if (dataType === ConfigurableEnumDatatype.dataType) { this.formAdditionalOptions = this.configurableEnumService .listEnums() - .map((x) => Entity.extractEntityIdFromId(x)); + .map((x) => ({ + label: Entity.extractEntityIdFromId(x), + value: Entity.extractEntityIdFromId(x), + })); // TODO allow new enum creation // TODO preview the options within the selected enum (and allow to edit the enum options?) } else if ( dataType === EntityDatatype.dataType || dataType === EntityArrayDatatype.dataType ) { - // TODO reuse and generalize ImportEntityTypeComponent.loadEntityTypes() - this.formAdditionalOptions = []; + this.formAdditionalOptions = this.entityRegistry + .getEntityTypes(true) + .map((x) => ({ label: x.value.label, value: x.value.ENTITY_TYPE })); } else { this.form.get("additional").setValue(null); this.formAdditionalOptions = null; diff --git a/src/app/core/entity/database-entity.decorator.ts b/src/app/core/entity/database-entity.decorator.ts index 9bc3d18abc..ae9d2f834d 100644 --- a/src/app/core/entity/database-entity.decorator.ts +++ b/src/app/core/entity/database-entity.decorator.ts @@ -1,7 +1,24 @@ import { Entity, EntityConstructor } from "./model/entity"; import { Registry } from "../config/registry/dynamic-registry"; -export class EntityRegistry extends Registry {} +export class EntityRegistry extends Registry { + /** + * Get an array of entity types, optionally filtered to exclude internal, administrative types. + * @param onlyUserFacing Whether to only include types that are explicitly defined and customized in the config, from which we infer they are user-facing. + */ + getEntityTypes( + onlyUserFacing = false, + ): { key: string; value: EntityConstructor }[] { + let entities = Array.from(this.entries()).map(([key, value]) => ({ + key, + value, + })); + if (onlyUserFacing) { + entities = entities.filter(({ key, value }) => value._isCustomizedType); + } + return entities; + } +} export const entityRegistry = new EntityRegistry((key, constructor) => { if (!(new constructor() instanceof Entity)) { diff --git a/src/app/core/import/import-entity-type/import-entity-type.component.ts b/src/app/core/import/import-entity-type/import-entity-type.component.ts index da3469aadd..337cc2105a 100644 --- a/src/app/core/import/import-entity-type/import-entity-type.component.ts +++ b/src/app/core/import/import-entity-type/import-entity-type.component.ts @@ -39,7 +39,7 @@ export class ImportEntityTypeComponent { } set expertMode(value: boolean) { - this.loadEntityTypes(value); + this.entityTypes = this.entityRegistry.getEntityTypes(!value); } private _expertMode: boolean = false; @@ -47,17 +47,7 @@ export class ImportEntityTypeComponent { entityTypes: { key: string; value: EntityConstructor }[]; constructor(public entityRegistry: EntityRegistry) { - this.loadEntityTypes(); - } - - private loadEntityTypes(expertMode?: boolean) { - let entities = Array.from(this.entityRegistry.entries()).map( - ([key, value]) => ({ key, value }), - ); - if (!expertMode) { - entities = entities.filter(({ key, value }) => value._isCustomizedType); - } - this.entityTypes = entities; + this.entityTypes = this.entityRegistry.getEntityTypes(true); } // TODO: infer entityType automatically -> pre-select + UI explanatory text diff --git a/src/app/core/import/import-entity-type/import-entity-type.stories.ts b/src/app/core/import/import-entity-type/import-entity-type.stories.ts index dfbb4ffd08..1e287c1d52 100644 --- a/src/app/core/import/import-entity-type/import-entity-type.stories.ts +++ b/src/app/core/import/import-entity-type/import-entity-type.stories.ts @@ -2,13 +2,14 @@ import { applicationConfig, Meta, StoryFn } from "@storybook/angular"; import { StorybookBaseModule } from "../../../utils/storybook-base.module"; import { ImportEntityTypeComponent } from "./import-entity-type.component"; import { importProvidersFrom } from "@angular/core"; +import { appInitializers } from "../../../app-initializers"; export default { title: "Features/Import/2 Select Entity Type", component: ImportEntityTypeComponent, decorators: [ applicationConfig({ - providers: [importProvidersFrom(StorybookBaseModule)], + providers: [importProvidersFrom(StorybookBaseModule), appInitializers], }), ], } as Meta; diff --git a/src/app/utils/storybook-base.module.ts b/src/app/utils/storybook-base.module.ts index 7ab847a729..c1f2eda74e 100644 --- a/src/app/utils/storybook-base.module.ts +++ b/src/app/utils/storybook-base.module.ts @@ -23,6 +23,7 @@ import { import { EntityMapperService } from "../core/entity/entity-mapper/entity-mapper.service"; import { DatabaseIndexingService } from "../core/entity/database-indexing/database-indexing.service"; import { createLocalSession, TEST_USER } from "./mock-local-session"; +import { EntityConfigService } from "../core/entity/entity-config.service"; componentRegistry.allowDuplicates(); entityRegistry.allowDuplicates(); @@ -78,10 +79,15 @@ export class StorybookBaseModule { StorybookBaseModule.initData = data; return StorybookBaseModule; } - constructor(icons: FaIconLibrary, entityMapper: EntityMapperService) { + constructor( + icons: FaIconLibrary, + entityMapper: EntityMapperService, + entityConfigService: EntityConfigService, + ) { (entityMapper as MockEntityMapperService).addAll( StorybookBaseModule.initData, ); icons.addIconPacks(fas, far); + entityConfigService.setupEntitiesFromConfig(); } } From 4a6900da5c9ad2a989c277ae03298c0a28271e1c Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Mon, 6 Nov 2023 16:17:01 +0100 Subject: [PATCH 010/132] human-readable dataType labels --- .../core/basic-datatypes/array/array.datatype.ts | 2 +- .../basic-datatypes/boolean/boolean.datatype.ts | 3 ++- .../configurable-enum.datatype.ts | 3 ++- .../basic-datatypes/date-only/date-only.datatype.ts | 3 ++- .../date-with-age/date-with-age.datatype.ts | 4 +++- src/app/core/basic-datatypes/date/date.datatype.ts | 3 ++- .../entity-array/entity-array.datatype.ts | 3 ++- .../core/basic-datatypes/entity/entity.datatype.ts | 4 +++- src/app/core/basic-datatypes/map/map.datatype.ts | 2 +- .../core/basic-datatypes/month/month.datatype.ts | 3 ++- .../core/basic-datatypes/number/number.datatype.ts | 4 +++- .../schema-embed/schema-embed.datatype.ts | 2 +- .../basic-datatypes/string/long-text.datatype.ts | 13 +++++++++++++ .../core/basic-datatypes/string/string.datatype.ts | 3 ++- .../config-field/config-field.component.ts | 13 ++----------- src/app/core/core.module.ts | 4 +++- .../entity/default-datatype/default.datatype.ts | 8 ++++++++ src/app/features/file/file.datatype.ts | 4 +++- src/app/features/location/location.datatype.ts | 5 +++-- .../recurring-interval/time-interval.datatype.ts | 3 ++- 20 files changed, 60 insertions(+), 29 deletions(-) create mode 100644 src/app/core/basic-datatypes/string/long-text.datatype.ts diff --git a/src/app/core/basic-datatypes/array/array.datatype.ts b/src/app/core/basic-datatypes/array/array.datatype.ts index 0d036455a7..ce5be8fd00 100644 --- a/src/app/core/basic-datatypes/array/array.datatype.ts +++ b/src/app/core/basic-datatypes/array/array.datatype.ts @@ -37,7 +37,7 @@ export class ArrayDatatype< EntityType = any, DBType = any, > extends DefaultDatatype { - static dataType = "array"; + static override dataType = "array"; // components for array should be handled by innerDatatype editComponent = undefined; diff --git a/src/app/core/basic-datatypes/boolean/boolean.datatype.ts b/src/app/core/basic-datatypes/boolean/boolean.datatype.ts index f8c57002ea..bcf34ee93a 100644 --- a/src/app/core/basic-datatypes/boolean/boolean.datatype.ts +++ b/src/app/core/basic-datatypes/boolean/boolean.datatype.ts @@ -3,7 +3,8 @@ import { DiscreteDatatype } from "../discrete/discrete.datatype"; @Injectable() export class BooleanDatatype extends DiscreteDatatype { - static dataType = "boolean"; + static override dataType = "boolean"; + static override label: string = $localize`:datatype-label:checkbox`; editComponent = "EditBoolean"; viewComponent = "DisplayCheckmark"; diff --git a/src/app/core/basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype.ts b/src/app/core/basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype.ts index cfd31e271e..83727b0a8b 100644 --- a/src/app/core/basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype.ts +++ b/src/app/core/basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype.ts @@ -9,7 +9,8 @@ export class ConfigurableEnumDatatype extends DiscreteDatatype< ConfigurableEnumValue, string > { - static dataType = "configurable-enum"; + static override dataType = "configurable-enum"; + static override label: string = $localize`:datatype-label:dropdown option`; public readonly viewComponent = "DisplayConfigurableEnum"; public readonly editComponent = "EditConfigurableEnum"; diff --git a/src/app/core/basic-datatypes/date-only/date-only.datatype.ts b/src/app/core/basic-datatypes/date-only/date-only.datatype.ts index 7f6da5509c..fa40dc96c2 100644 --- a/src/app/core/basic-datatypes/date-only/date-only.datatype.ts +++ b/src/app/core/basic-datatypes/date-only/date-only.datatype.ts @@ -30,7 +30,8 @@ import { DateDatatype } from "../date/date.datatype"; */ @Injectable() export class DateOnlyDatatype extends DateDatatype { - static dataType = "date-only"; + static override dataType = "date-only"; + static override label: string = $localize`:datatype-label:date`; transformToDatabaseFormat(value: Date) { if (!(value instanceof Date)) { diff --git a/src/app/core/basic-datatypes/date-with-age/date-with-age.datatype.ts b/src/app/core/basic-datatypes/date-with-age/date-with-age.datatype.ts index 50ab219e90..b48dc1b8a7 100644 --- a/src/app/core/basic-datatypes/date-with-age/date-with-age.datatype.ts +++ b/src/app/core/basic-datatypes/date-with-age/date-with-age.datatype.ts @@ -7,7 +7,9 @@ import { DateWithAge } from "./dateWithAge"; */ @Injectable() export class DateWithAgeDatatype extends DateOnlyDatatype { - static dataType = "date-with-age"; + static override dataType = "date-with-age"; + static override label: string = $localize`:datatype-label:date with age`; + editComponent = "EditAge"; transformToObjectFormat(value): DateWithAge { diff --git a/src/app/core/basic-datatypes/date/date.datatype.ts b/src/app/core/basic-datatypes/date/date.datatype.ts index d18777f913..ad4442f233 100644 --- a/src/app/core/basic-datatypes/date/date.datatype.ts +++ b/src/app/core/basic-datatypes/date/date.datatype.ts @@ -35,7 +35,8 @@ export class DateDatatype extends DefaultDatatype< Date, DBFormat > { - static dataType = "date"; + static override dataType = "date"; + static override label: string = $localize`:datatype-label:date (with time)`; viewComponent = "DisplayDate"; editComponent = "EditDate"; diff --git a/src/app/core/basic-datatypes/entity-array/entity-array.datatype.ts b/src/app/core/basic-datatypes/entity-array/entity-array.datatype.ts index 543873e13e..c1b6073d3d 100644 --- a/src/app/core/basic-datatypes/entity-array/entity-array.datatype.ts +++ b/src/app/core/basic-datatypes/entity-array/entity-array.datatype.ts @@ -31,7 +31,8 @@ import { ArrayDatatype } from "../array/array.datatype"; */ @Injectable() export class EntityArrayDatatype extends ArrayDatatype { - static dataType = "entity-array"; + static override dataType = "entity-array"; + static override label: string = $localize`:datatype-label:link to other records (multi-select)`; editComponent = "EditEntityArray"; viewComponent = "DisplayEntityArray"; diff --git a/src/app/core/basic-datatypes/entity/entity.datatype.ts b/src/app/core/basic-datatypes/entity/entity.datatype.ts index 33070ffc5b..eb8f4f6be2 100644 --- a/src/app/core/basic-datatypes/entity/entity.datatype.ts +++ b/src/app/core/basic-datatypes/entity/entity.datatype.ts @@ -33,7 +33,9 @@ import { EntityRemoveService } from "../../entity/entity-remove.service"; */ @Injectable() export class EntityDatatype extends StringDatatype { - static dataType = "entity"; + static override dataType = "entity"; + static override label: string = $localize`:datatype-label:link to another record`; + editComponent = "EditSingleEntity"; viewComponent = "DisplayEntity"; importConfigComponent = "EntityImportConfig"; diff --git a/src/app/core/basic-datatypes/map/map.datatype.ts b/src/app/core/basic-datatypes/map/map.datatype.ts index 8320c33bcc..a173f1e958 100644 --- a/src/app/core/basic-datatypes/map/map.datatype.ts +++ b/src/app/core/basic-datatypes/map/map.datatype.ts @@ -37,7 +37,7 @@ export class MapDatatype extends DefaultDatatype< Map, [string, any][] > { - static dataType = "map"; + static override dataType = "map"; constructor(private schemaService: EntitySchemaService) { super(); diff --git a/src/app/core/basic-datatypes/month/month.datatype.ts b/src/app/core/basic-datatypes/month/month.datatype.ts index 10df496193..48228b5dd6 100644 --- a/src/app/core/basic-datatypes/month/month.datatype.ts +++ b/src/app/core/basic-datatypes/month/month.datatype.ts @@ -29,7 +29,8 @@ import { DateOnlyDatatype } from "../date-only/date-only.datatype"; */ @Injectable() export class MonthDatatype extends DateOnlyDatatype { - static dataType = "month"; + static override dataType = "month"; + static override label: string = $localize`:datatype-label:month (date without day of month)`; viewComponent = "DisplayMonth"; editComponent = "EditMonth"; diff --git a/src/app/core/basic-datatypes/number/number.datatype.ts b/src/app/core/basic-datatypes/number/number.datatype.ts index 6e7052a709..9a41272ce8 100644 --- a/src/app/core/basic-datatypes/number/number.datatype.ts +++ b/src/app/core/basic-datatypes/number/number.datatype.ts @@ -32,7 +32,9 @@ import { Injectable } from "@angular/core"; */ @Injectable() export class NumberDatatype extends DefaultDatatype { - static dataType = "number"; + static override dataType = "number"; + static override label: string = $localize`:datatype-label:number`; + viewComponent = "DisplayText"; editComponent = "EditNumber"; diff --git a/src/app/core/basic-datatypes/schema-embed/schema-embed.datatype.ts b/src/app/core/basic-datatypes/schema-embed/schema-embed.datatype.ts index 6f456fb9e8..1508ed13f0 100644 --- a/src/app/core/basic-datatypes/schema-embed/schema-embed.datatype.ts +++ b/src/app/core/basic-datatypes/schema-embed/schema-embed.datatype.ts @@ -38,7 +38,7 @@ import { Injectable } from "@angular/core"; */ @Injectable() export class SchemaEmbedDatatype extends DefaultDatatype { - static dataType = "schema-embed"; + static override dataType = "schema-embed"; constructor(private schemaService: EntitySchemaService) { super(); diff --git a/src/app/core/basic-datatypes/string/long-text.datatype.ts b/src/app/core/basic-datatypes/string/long-text.datatype.ts new file mode 100644 index 0000000000..35880b28c6 --- /dev/null +++ b/src/app/core/basic-datatypes/string/long-text.datatype.ts @@ -0,0 +1,13 @@ +import { Injectable } from "@angular/core"; +import { StringDatatype } from "./string.datatype"; + +/** + * Datatype for multi-line string fields. + */ +@Injectable() +export class LongTextDatatype extends StringDatatype { + static override dataType = "long-text"; + static override label: string = $localize`:datatype-label:text (long)`; + + editComponent: "EditLongText"; +} diff --git a/src/app/core/basic-datatypes/string/string.datatype.ts b/src/app/core/basic-datatypes/string/string.datatype.ts index 390a24f2ef..031cc1447f 100644 --- a/src/app/core/basic-datatypes/string/string.datatype.ts +++ b/src/app/core/basic-datatypes/string/string.datatype.ts @@ -32,7 +32,8 @@ import { DefaultDatatype } from "../../entity/default-datatype/default.datatype" */ @Injectable() export class StringDatatype extends DefaultDatatype { - static dataType = "string"; + static override dataType = "string"; + static override label: string = $localize`:datatype-label:text`; transformToDatabaseFormat(value) { return String(value); diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index 50bbae33ec..9228bfc3e5 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -25,9 +25,6 @@ import { ConfigurableEnumDatatype } from "../../basic-datatypes/configurable-enu import { EntityDatatype } from "../../basic-datatypes/entity/entity.datatype"; import { EntityArrayDatatype } from "../../basic-datatypes/entity-array/entity-array.datatype"; import { ConfigurableEnumService } from "../../basic-datatypes/configurable-enum/configurable-enum.service"; -import { ArrayDatatype } from "../../basic-datatypes/array/array.datatype"; -import { SchemaEmbedDatatype } from "../../basic-datatypes/schema-embed/schema-embed.datatype"; -import { MapDatatype } from "../../basic-datatypes/map/map.datatype"; import { EntityRegistry } from "../../entity/database-entity.decorator"; /** @@ -148,17 +145,11 @@ export class ConfigFieldComponent { private initAvailableDatatypes(dataTypes: DefaultDatatype[]) { this.dataTypes = dataTypes - .filter( - (d) => - d.dataType !== ArrayDatatype.dataType && - d.dataType !== SchemaEmbedDatatype.dataType && - d.dataType !== MapDatatype.dataType, - ) + .filter((d) => d.label !== DefaultDatatype.label) // hide "internal" technical dataTypes that did not define a human-readable label .map((d) => ({ - label: d.dataType, + label: d.label, value: d.dataType, })); - // TODO: human-readable names for data types } objectToLabel = (v: { label: string }) => v?.label; objectToValue = (v: { value: string }) => v?.value; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index a87e0fc30c..3955d7ec9b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -19,6 +19,7 @@ import { NumberDatatype } from "./basic-datatypes/number/number.datatype"; import { Entity } from "./entity/model/entity"; import { TimePeriod } from "./entity-details/related-time-period-entities/time-period"; import { CommonModule } from "@angular/common"; +import { LongTextDatatype } from "./basic-datatypes/string/long-text.datatype"; /** * Core module registering basic parts like datatypes and components. @@ -27,15 +28,16 @@ import { CommonModule } from "@angular/common"; providers: [ // base dataTypes { provide: DefaultDatatype, useClass: StringDatatype, multi: true }, + { provide: DefaultDatatype, useClass: LongTextDatatype, multi: true }, { provide: DefaultDatatype, useClass: BooleanDatatype, multi: true }, { provide: DefaultDatatype, useClass: NumberDatatype, multi: true }, { provide: DefaultDatatype, useClass: SchemaEmbedDatatype, multi: true }, { provide: DefaultDatatype, useClass: ArrayDatatype, multi: true }, { provide: DefaultDatatype, useClass: MapDatatype, multi: true }, - { provide: DefaultDatatype, useClass: DateDatatype, multi: true }, { provide: DefaultDatatype, useClass: DateOnlyDatatype, multi: true }, { provide: DefaultDatatype, useClass: DateWithAgeDatatype, multi: true }, { provide: DefaultDatatype, useClass: MonthDatatype, multi: true }, + { provide: DefaultDatatype, useClass: DateDatatype, multi: true }, { provide: DefaultDatatype, useClass: EntityDatatype, multi: true }, { provide: DefaultDatatype, useClass: EntityArrayDatatype, multi: true }, ], diff --git a/src/app/core/entity/default-datatype/default.datatype.ts b/src/app/core/entity/default-datatype/default.datatype.ts index 9545df82c2..ce1934ef41 100644 --- a/src/app/core/entity/default-datatype/default.datatype.ts +++ b/src/app/core/entity/default-datatype/default.datatype.ts @@ -42,6 +42,14 @@ export class DefaultDatatype { return (this.constructor as typeof DefaultDatatype).dataType; } + /** + * The human-readable name for this dataType, used in config UIs. + */ + static label: string = $localize`:datatype-label:any`; + get label(): string { + return (this.constructor as typeof DefaultDatatype).label; + } + /** * The default component how this datatype should be displayed in lists and forms. * diff --git a/src/app/features/file/file.datatype.ts b/src/app/features/file/file.datatype.ts index a4c432f8b1..adb2dce16b 100644 --- a/src/app/features/file/file.datatype.ts +++ b/src/app/features/file/file.datatype.ts @@ -24,7 +24,9 @@ import { EntitySchemaField } from "../../core/entity/schema/entity-schema-field" */ @Injectable() export class FileDatatype extends StringDatatype { - static dataType = "file"; + static override dataType = "file"; + static override label: string = $localize`:datatype-label:file attachment`; + viewComponent = "ViewFile"; editComponent = "EditFile"; diff --git a/src/app/features/location/location.datatype.ts b/src/app/features/location/location.datatype.ts index a68a0392e3..2d6173de1f 100644 --- a/src/app/features/location/location.datatype.ts +++ b/src/app/features/location/location.datatype.ts @@ -1,12 +1,13 @@ import { DefaultDatatype } from "../../core/entity/default-datatype/default.datatype"; import { Injectable } from "@angular/core"; -import { EntitySchemaField } from "../../core/entity/schema/entity-schema-field"; import { GeoResult, GeoService } from "./geo.service"; import { lastValueFrom } from "rxjs"; @Injectable() export class LocationDatatype extends DefaultDatatype { - static dataType = "location"; + static override dataType = "location"; + static override label: string = $localize`:datatype-label:location (address + map)`; + editComponent = "EditLocation"; viewComponent = "ViewLocation"; diff --git a/src/app/features/todos/recurring-interval/time-interval.datatype.ts b/src/app/features/todos/recurring-interval/time-interval.datatype.ts index bc0e61d9b2..1ef9a8ca2c 100644 --- a/src/app/features/todos/recurring-interval/time-interval.datatype.ts +++ b/src/app/features/todos/recurring-interval/time-interval.datatype.ts @@ -4,7 +4,8 @@ import { DefaultDatatype } from "../../../core/entity/default-datatype/default.d * Datatype for defining a time interval. */ export class TimeIntervalDatatype extends DefaultDatatype { - static dataType = "time-interval"; + static override dataType = "time-interval"; + static override label: string = $localize`:datatype-label:time interval`; viewComponent = "DisplayRecurringInterval"; editComponent = "EditRecurringInterval"; From 2e28d3dd7f3acc5a5b59bf8841297ba914a97b2c Mon Sep 17 00:00:00 2001 From: Brajesh Kumar <78679532+brajesh-lab@users.noreply.github.com> Date: Mon, 6 Nov 2023 16:59:04 +0530 Subject: [PATCH 011/132] feat(*): allow to export only displayed, filtered data (#2059) closes #1361 Co-authored-by: Sebastian Leidig --- .../entity-subrecord.component.ts | 7 ++++++- .../entity-list/entity-list.component.html | 21 ++++++++++++++++++- .../entity-list/entity-list.component.ts | 6 +++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/app/core/common-components/entity-subrecord/entity-subrecord/entity-subrecord.component.ts b/src/app/core/common-components/entity-subrecord/entity-subrecord/entity-subrecord.component.ts index 6898a18534..7fa7369918 100644 --- a/src/app/core/common-components/entity-subrecord/entity-subrecord/entity-subrecord.component.ts +++ b/src/app/core/common-components/entity-subrecord/entity-subrecord/entity-subrecord.component.ts @@ -94,7 +94,6 @@ export interface TableRow { }) export class EntitySubrecordComponent implements OnChanges { @Input() isLoading: boolean; - @Input() clickMode: "popup" | "navigate" | "none" = "popup"; @Input() showInactive = false; @@ -115,6 +114,9 @@ export class EntitySubrecordComponent implements OnChanges { /** data to be displayed, can also be used as two-way-binding */ @Input() records: T[] = []; + /** output the currently displayed records, whenever filters for the user change */ + @Output() filteredRecordsChange = new EventEmitter(); + /** * factory method to create a new instance of the displayed Entity type * used when the user adds a new entity to the list. @@ -266,6 +268,9 @@ export class EntitySubrecordComponent implements OnChanges { this.sortDefault(); } + this.filteredRecordsChange.emit( + this.recordsDataSource.filteredData.map((item) => item.record), + ); this.listenToEntityUpdates(); } diff --git a/src/app/core/entity-list/entity-list/entity-list.component.html b/src/app/core/entity-list/entity-list/entity-list.component.html index c90de3afd5..1c3ebea433 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.html +++ b/src/app/core/entity-list/entity-list/entity-list.component.html @@ -129,6 +129,7 @@

{{ listName }}

[filter]="filterObj" [defaultSort]="listConfig?.defaultSort" [showInactive]="showInactive" + (filteredRecordsChange)="filteredData = $event" > @@ -183,7 +184,25 @@

{{ listName }}

aria-label="download csv" icon="download" > - Download CSV + Download all data (.csv) + + + + + diff --git a/src/app/core/entity-list/entity-list/entity-list.component.ts b/src/app/core/entity-list/entity-list/entity-list.component.ts index a65f07311a..3caad25f75 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.ts @@ -43,6 +43,8 @@ import { TabStateModule } from "../../../utils/tab-state/tab-state.module"; import { ViewTitleComponent } from "../../common-components/view-title/view-title.component"; import { ExportDataDirective } from "../../export/export-data-directive/export-data.directive"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; +import { DuplicateRecordService } from "../duplicate-records/duplicate-records.service"; +import { MatTooltipModule } from "@angular/material/tooltip"; /** * This component allows to create a full-blown table with pagination, filtering, searching and grouping. @@ -58,6 +60,7 @@ import { DisableEntityOperationDirective } from "../../permissions/permission-di selector: "app-entity-list", templateUrl: "./entity-list.component.html", styleUrls: ["./entity-list.component.scss"], + providers: [DuplicateRecordService], imports: [ NgIf, NgStyle, @@ -78,6 +81,7 @@ import { DisableEntityOperationDirective } from "../../permissions/permission-di ExportDataDirective, DisableEntityOperationDirective, RouterLink, + MatTooltipModule, ], standalone: true, }) @@ -98,6 +102,7 @@ export class EntityListComponent @Output() elementClick = new EventEmitter(); @Output() addNewClick = new EventEmitter(); + selectedRows: T[] = []; @ViewChild(EntitySubrecordComponent) entityTable: EntitySubrecordComponent; @@ -148,6 +153,7 @@ export class EntityListComponent private entityMapperService: EntityMapperService, private entities: EntityRegistry, private dialog: MatDialog, + private duplicateRecord: DuplicateRecordService, ) { // TODO: refactor the EntityListComponent to make use of RoutedViewComponent and not load the route data itself if (this.activatedRoute.component === EntityListComponent) { @@ -306,4 +312,9 @@ export class EntityListComponent } this.addNewClick.emit(); } + + duplicateRecords() { + this.duplicateRecord.duplicateRecord(this.selectedRows); + this.selectedRows = []; + } } From c10c49ae00acceab6fd994560db4f009b1082dc2 Mon Sep 17 00:00:00 2001 From: Rudresh Shrotriya Date: Mon, 6 Nov 2023 18:56:18 +0530 Subject: [PATCH 013/132] refactor: lint check for prettier formatting (#2039) all prettier rules are enforced by lint check now --------- Co-authored-by: Sebastian Leidig --- .eslintrc.json | 7 +- .prettierrc.json | 16 + package-lock.json | 330 ++++++++++++++++++ package.json | 2 + .../edit-attendance.component.spec.ts | 7 +- .../child-block/child-block.component.html | 11 +- .../entity-import-config.component.html | 34 +- src/app/core/export/query.service.spec.ts | 5 +- .../filter-generator.service.ts | 5 +- .../progress-dashboard-widget.module.ts | 6 +- .../display-img/display-img.component.html | 9 +- .../file/edit-photo/edit-photo.component.html | 10 +- src/index.html | 235 ++++++++----- 13 files changed, 564 insertions(+), 113 deletions(-) create mode 100644 .prettierrc.json diff --git a/.eslintrc.json b/.eslintrc.json index f93cd6e7bf..97d9fbec73 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -50,7 +50,12 @@ } } ], + "rules": { + "prettier/prettier": "error" + }, + "plugins": ["prettier"], "extends": [ - "plugin:storybook/recommended" + "plugin:storybook/recommended", + "prettier" ] } diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000000..0567403f9e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,16 @@ +{ + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "html" + } + }, + { + "files": "*.component.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 1ebebe9b57..9ebe06f0ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,8 @@ "babel-loader": "^9.1.3", "cypress": "13.1.0", "eslint": "^8.47.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-storybook": "^0.6.13", "jasmine-core": "^5.1.0", "jasmine-spec-reporter": "^7.0.0", @@ -6213,6 +6215,56 @@ "node": ">=14" } }, + "node_modules/@pkgr/utils": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", + "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "fast-glob": "^3.3.0", + "is-glob": "^4.0.3", + "open": "^9.1.0", + "picocolors": "^1.0.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@pkgr/utils/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@pkgr/utils/node_modules/open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "dev": true, + "dependencies": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -11643,6 +11695,21 @@ "semver": "^7.0.0" } }, + "node_modules/bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "dev": true, + "dependencies": { + "run-applescript": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -13542,6 +13609,24 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "dev": true, + "dependencies": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-browser-id": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", @@ -13558,6 +13643,116 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-browser/node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/default-browser/node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/default-browser/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -14610,6 +14805,47 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", + "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", + "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-storybook": { "version": "0.6.15", "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-0.6.15.tgz", @@ -15217,6 +15453,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -17357,6 +17599,39 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-installed-globally": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", @@ -21914,6 +22189,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -23277,6 +23564,21 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -24762,6 +25064,22 @@ "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", "dev": true }, + "node_modules/synckit": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz", + "integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==", + "dev": true, + "dependencies": { + "@pkgr/utils": "^2.3.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -25216,6 +25534,18 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "dev": true }, + "node_modules/titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", diff --git a/package.json b/package.json index 2e64e2dc2a..8a02329de8 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,8 @@ "babel-loader": "^9.1.3", "cypress": "13.1.0", "eslint": "^8.47.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-storybook": "^0.6.13", "jasmine-core": "^5.1.0", "jasmine-spec-reporter": "^7.0.0", diff --git a/src/app/child-dev-project/attendance/edit-attendance/edit-attendance.component.spec.ts b/src/app/child-dev-project/attendance/edit-attendance/edit-attendance.component.spec.ts index 069ca990ce..bee7d4c417 100644 --- a/src/app/child-dev-project/attendance/edit-attendance/edit-attendance.component.spec.ts +++ b/src/app/child-dev-project/attendance/edit-attendance/edit-attendance.component.spec.ts @@ -85,9 +85,10 @@ describe("EditAttendanceComponent", () => { categoryForm.setValue(defaultInteractionTypes.find((c) => c.isMeeting)); fixture.detectChanges(); - const inputElements = await TestbedHarnessEnvironment.loader( - fixture, - ).getAllHarnesses(MatInputHarness); + const inputElements = + await TestbedHarnessEnvironment.loader(fixture).getAllHarnesses( + MatInputHarness, + ); const firstRemarkInput = inputElements[1]; await firstRemarkInput.setValue("new remarks"); diff --git a/src/app/child-dev-project/children/child-block/child-block.component.html b/src/app/child-dev-project/children/child-block/child-block.component.html index 8b3c1815f1..a5e2166e74 100644 --- a/src/app/child-dev-project/children/child-block/child-block.component.html +++ b/src/app/child-dev-project/children/child-block/child-block.component.html @@ -5,9 +5,16 @@ [class.inactive]="!entity.isActive" class="truncate-text container" > - + {{ entity?.toString() }} - ({{ entity?.projectNumber }}) + + ({{ entity?.projectNumber }}) diff --git a/src/app/core/basic-datatypes/entity/entity-import-config/entity-import-config.component.html b/src/app/core/basic-datatypes/entity/entity-import-config/entity-import-config.component.html index 0f80445fcf..d7a6e0cc7b 100644 --- a/src/app/core/basic-datatypes/entity/entity-import-config/entity-import-config.component.html +++ b/src/app/core/basic-datatypes/entity/entity-import-config/entity-import-config.component.html @@ -1,19 +1,25 @@ -

Select matching {{ entity.label }} property

- - - {{ property.label }} - - - +

+ Select matching {{ entity.label }} property +

+ + + {{ property.label }} + + +
- - + + diff --git a/src/app/core/export/query.service.spec.ts b/src/app/core/export/query.service.spec.ts index 1b346dfa12..67175f460f 100644 --- a/src/app/core/export/query.service.spec.ts +++ b/src/app/core/export/query.service.spec.ts @@ -497,9 +497,8 @@ describe("QueryService", () => { const attendanceArrayQuery = `${EventNote.ENTITY_TYPE}:toArray:getAttendanceArray(true)`; - const attendanceResult: AttendanceInfo = await queryData( - attendanceArrayQuery, - ); + const attendanceResult: AttendanceInfo = + await queryData(attendanceArrayQuery); expect(attendanceResult).toContain({ participant: presentTwiceWithSchool.getId(), diff --git a/src/app/core/filter/filter-generator/filter-generator.service.ts b/src/app/core/filter/filter-generator/filter-generator.service.ts index 60c9fd0094..a07e83e514 100644 --- a/src/app/core/filter/filter-generator/filter-generator.service.ts +++ b/src/app/core/filter/filter-generator/filter-generator.service.ts @@ -90,9 +90,8 @@ export class FilterGeneratorService { this.entities.has(schema.additional) ) { const entityType = filterConfig.type || schema.additional; - const filterEntities = await this.entityMapperService.loadType( - entityType, - ); + const filterEntities = + await this.entityMapperService.loadType(entityType); filter = new EntityFilter( filterConfig.id, filterConfig.label || schema.label, diff --git a/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard-widget.module.ts b/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard-widget.module.ts index c015dcc7b0..a05f2445e7 100644 --- a/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard-widget.module.ts +++ b/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard-widget.module.ts @@ -1,6 +1,6 @@ -import {NgModule} from "@angular/core"; -import {ComponentRegistry} from "../../../dynamic-components"; -import {ProgressDashboardConfig} from "./progress-dashboard/progress-dashboard-config"; +import { NgModule } from "@angular/core"; +import { ComponentRegistry } from "../../../dynamic-components"; +import { ProgressDashboardConfig } from "./progress-dashboard/progress-dashboard-config"; @NgModule({}) export class ProgressDashboardWidgetModule { diff --git a/src/app/features/file/display-img/display-img.component.html b/src/app/features/file/display-img/display-img.component.html index baee6d3910..2096edb1b3 100644 --- a/src/app/features/file/display-img/display-img.component.html +++ b/src/app/features/file/display-img/display-img.component.html @@ -1,3 +1,6 @@ - - - \ No newline at end of file + + + diff --git a/src/app/features/file/edit-photo/edit-photo.component.html b/src/app/features/file/edit-photo/edit-photo.component.html index fd84cae2eb..d956f66411 100644 --- a/src/app/features/file/edit-photo/edit-photo.component.html +++ b/src/app/features/file/edit-photo/edit-photo.component.html @@ -13,8 +13,14 @@ > - - - - Aam Digital - + + + Aam Digital + - - + + - - + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - -
-

Aam Digital

-

... is loading for the first time.

-

- This may take a few seconds or even minutes depending on your internet - connection. -

-

- Browser Details: - -

-
-
- - + + + + + + + +
+

Aam Digital

+

... is loading for the first time.

+

+ This may take a few seconds or even minutes depending on your internet + connection. +

+

+ Browser Details: + +

+
+
+ From 6a1ef724dccf81f739b85d3f6350463db5f73006 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Tue, 7 Nov 2023 09:32:35 +0100 Subject: [PATCH 014/132] pass schemaField into config popup only --- .../entity-form/entity-form.service.ts | 31 +++++---- .../config-entity-form.component.html | 5 +- .../config-entity-form.component.ts | 42 +++++++++--- .../config-field/config-field.component.html | 3 +- .../config-field/config-field.component.ts | 64 ++++++++++--------- 5 files changed, 93 insertions(+), 52 deletions(-) diff --git a/src/app/core/common-components/entity-form/entity-form.service.ts b/src/app/core/common-components/entity-form/entity-form.service.ts index 6484537059..8e985b4ffe 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.ts @@ -71,7 +71,11 @@ export class EntityFormService { const fullFields: FormFieldConfig[] = formFields.map(toFormFieldConfig); for (const formField of fullFields) { try { - this.addFormFields(formField, entityType, forTable); + this.addSchemaToFormField( + formField, + entityType.schema.get(formField.id), + forTable, + ); } catch (err) { throw new Error( `Could not create form config for ${formField.id}: ${err}`, @@ -81,32 +85,33 @@ export class EntityFormService { return fullFields; } - private addFormFields( + addSchemaToFormField( formField: FormFieldConfig, - entityType: EntityConstructor, + schemaField: EntitySchemaField, forTable: boolean, - ) { - const propertySchema = entityType.schema.get(formField.id); + ): FormFieldConfig { formField.edit = formField.edit || - this.entitySchemaService.getComponent(propertySchema, "edit"); + this.entitySchemaService.getComponent(schemaField, "edit"); formField.view = formField.view || - this.entitySchemaService.getComponent(propertySchema, "view"); - formField.tooltip = formField.tooltip || propertySchema?.description; - formField.additional = formField.additional || propertySchema?.additional; + this.entitySchemaService.getComponent(schemaField, "view"); + formField.tooltip = formField.tooltip || schemaField?.description; + formField.additional = formField.additional || schemaField?.additional; if (forTable) { formField.forTable = true; formField.label = - formField.label || propertySchema.labelShort || propertySchema.label; + formField.label || schemaField.labelShort || schemaField.label; } else { formField.forTable = false; formField.label = - formField.label || propertySchema.label || propertySchema.labelShort; + formField.label || schemaField.label || schemaField.labelShort; } - if (propertySchema?.validators) { - formField.validators = propertySchema?.validators; + if (schemaField?.validators) { + formField.validators = schemaField?.validators; } + + return formField; } /** diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index 9e22ac993f..cac71bf494 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -47,7 +47,10 @@ cdkDragHandle > -
+
{ + if (!updatedFieldSchema) { + return; + } + + updatedFieldSchema = this.entityFormService.addSchemaToFormField( + { id: field.id }, + updatedFieldSchema, + false, + ); + this.entitySchema.set(field.id, updatedFieldSchema); + fieldsArray.splice(fieldsArray.indexOf(field), 1, updatedFieldSchema); + }); } drop(event: CdkDragDrop) { const item = event.previousContainer.data[event.previousIndex]; if (item.id === null) { - const newField = {}; + const newField = { id: null }; event.container.data.splice(event.currentIndex, 0, newField); - this.openFieldConfig(newField); + this.openFieldConfig(newField, event.container.data); return; } diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html index fce07bba9c..b903d001e5 100644 --- a/src/app/core/config-ui/config-field/config-field.component.html +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -1,4 +1,4 @@ -

Configure Field "{{ schemaFieldConfig.label }}"

+

Configure Field "{{ entitySchemaField.label }}"

@@ -122,5 +122,6 @@

Configure Field "{{ schemaFieldConfig.label }}"

+ diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index 9228bfc3e5..824b0a5551 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -1,7 +1,10 @@ import { Component, Inject, Input } from "@angular/core"; -import { Entity, EntityConstructor } from "../../entity/model/entity"; -import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; -import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog"; +import { Entity } from "../../entity/model/entity"; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from "@angular/material/dialog"; import { MatButtonModule } from "@angular/material/button"; import { DialogCloseComponent } from "../../common-components/dialog-close/dialog-close.component"; import { MatInputModule } from "@angular/material/input"; @@ -55,11 +58,7 @@ import { EntityRegistry } from "../../entity/database-entity.decorator"; ], }) export class ConfigFieldComponent { - @Input() entityType: EntityConstructor; - @Input() formFieldConfig: FormFieldConfig; - field: string; - - schemaFieldConfig: EntitySchemaField & { id?: string }; // TODO: add id / key to EntitySchemaField for easier handling? + @Input() entitySchemaField: EntitySchemaField & { id?: string }; // TODO: add id / key to EntitySchemaField for easier handling? form: FormGroup; formLabelShort: FormControl; @@ -71,51 +70,48 @@ export class ConfigFieldComponent { constructor( @Inject(MAT_DIALOG_DATA) data: { - entityType: EntityConstructor; - formFieldConfig: FormFieldConfig; + entitySchemaField: EntitySchemaField; }, + private dialogRef: MatDialogRef, private fb: FormBuilder, @Inject(DefaultDatatype) dataTypes: DefaultDatatype[], private configurableEnumService: ConfigurableEnumService, private entityRegistry: EntityRegistry, ) { - this.entityType = data.entityType; - this.formFieldConfig = data.formFieldConfig; - // TODO: merge formField and schemaField config interfaces to be exactly matching, simply enabling direct overwrites? - this.schemaFieldConfig = { - ...this.entityType.schema.get(this.formFieldConfig.id), - id: this.formFieldConfig.id, - }; + this.entitySchemaField = data.entitySchemaField; this.initSettings(); this.initAvailableDatatypes(dataTypes); } private initSettings() { - this.formLabelShort = this.fb.control(this.schemaFieldConfig.labelShort); - this.formAdditional = this.fb.control(this.schemaFieldConfig.additional); + this.formLabelShort = this.fb.control(this.entitySchemaField.labelShort); + this.formAdditional = this.fb.control(this.entitySchemaField.additional); this.form = this.fb.group({ - label: [this.schemaFieldConfig.label], + label: [this.entitySchemaField.label], labelShort: this.formLabelShort, - description: [this.schemaFieldConfig.description], + description: [this.entitySchemaField.description], - id: this.fb.control({ value: this.schemaFieldConfig.id, disabled: true }), - dataType: [this.schemaFieldConfig.dataType], - additional: [this.schemaFieldConfig.additional], + id: this.fb.control({ + value: this.entitySchemaField.id, + disabled: this.entitySchemaField.id !== null, // disabled if not newly created field + }), + dataType: [this.entitySchemaField.dataType], + additional: [this.entitySchemaField.additional], // TODO: remove "innerDataType" completely - the UI can only support very specific multi-valued types anyway // TODO add a datatype "alias" for enum-array - innerDataType: [this.schemaFieldConfig.innerDataType], + innerDataType: [this.entitySchemaField.innerDataType], - defaultValue: [this.schemaFieldConfig.defaultValue], - searchable: [this.schemaFieldConfig.searchable], - anonymize: [this.schemaFieldConfig.anonymize], + defaultValue: [this.entitySchemaField.defaultValue], + searchable: [this.entitySchemaField.searchable], + anonymize: [this.entitySchemaField.anonymize], //viewComponent: [], //editComponent: [], //showInDetailsView: [], //generateIndex: [], - validators: [this.schemaFieldConfig.validators], + validators: [this.entitySchemaField.validators], }); this.updateShortLabelToggle(!!this.formLabelShort.value); @@ -180,4 +176,14 @@ export class ConfigFieldComponent { // TODO: this mapping of having an "additional" schema should probably become part of Datatype classes } + + save() { + const updatedEntitySchema = Object.assign( + {}, + this.entitySchemaField, + this.form.getRawValue(), + ); + + this.dialogRef.close(updatedEntitySchema); + } } From 4345d249b6dba89a83fd62ac421b74b12c6ea81a Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 11:34:12 +0100 Subject: [PATCH 015/132] correctly add a new field's schema --- .../entity-form/entity-form.component.scss | 3 +- .../config-entity-form.component.html | 2 + .../config-entity-form.component.scss | 5 +- .../config-entity-form.component.ts | 53 +++++++++++++++---- .../config-field/config-field.component.ts | 14 ++--- src/styles/variables/_sizes.scss | 2 + 6 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/app/core/common-components/entity-form/entity-form/entity-form.component.scss b/src/app/core/common-components/entity-form/entity-form/entity-form.component.scss index 2b8bf10f39..3a5b14d133 100644 --- a/src/app/core/common-components/entity-form/entity-form/entity-form.component.scss +++ b/src/app/core/common-components/entity-form/entity-form/entity-form.component.scss @@ -1,8 +1,9 @@ @use "src/styles/mixins/grid-layout"; +@use "src/styles/variables/sizes"; .grid-layout { @include grid-layout.adaptive( - $min-block-width: 250px, + $min-block-width: sizes.$form-group-min-width, $max-screen-width: 414px ); } diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index cac71bf494..02c60638e6 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -50,8 +50,10 @@
{ + .subscribe((updatedFieldSchema: EntitySchemaField_withId) => { if (!updatedFieldSchema) { return; } - updatedFieldSchema = this.entityFormService.addSchemaToFormField( - { id: field.id }, - updatedFieldSchema, - false, - ); - this.entitySchema.set(field.id, updatedFieldSchema); - fieldsArray.splice(fieldsArray.indexOf(field), 1, updatedFieldSchema); + const updatedFormField = this.saveSchemaField(updatedFieldSchema); + fieldsArray.splice(fieldsArray.indexOf(field), 1, updatedFormField); }); } + private saveSchemaField( + schemaField: EntitySchemaField_withId, + ): FormFieldConfig { + this.entitySchema.set(schemaField.id, schemaField); + + const updatedFormField = this.entityFormService.addSchemaToFormField( + { id: schemaField.id }, + schemaField, + false, + ); + if (!this.dummyForm.get(schemaField.id)) { + const newFormGroup = this.entityFormService.createFormGroup( + [updatedFormField], + this.dummyEntity, + ); + this.dummyForm.addControl( + schemaField.id, + newFormGroup.get(schemaField.id), + ); + this.dummyForm.disable(); + } + + return updatedFormField; + } + drop(event: CdkDragDrop) { const item = event.previousContainer.data[event.previousIndex]; if (item.id === null) { @@ -129,4 +152,16 @@ export class ConfigEntityFormComponent implements OnChanges { removeGroup(i: number) { this.fieldGroups.splice(i, 1); } + + private async createNewField(fieldGroup: any[], index: number) { + const newField = { id: null }; + await this.openFieldConfig(newField, fieldGroup); + + fieldGroup.splice(index, 0, newField); + } +} + +export interface FieldGroup { + header?: string; + fields: FormFieldConfig[]; } diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index 824b0a5551..3d5ba23842 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -64,8 +64,8 @@ export class ConfigFieldComponent { formLabelShort: FormControl; useShortLabel: boolean; formAdditional: FormControl; - formAdditionalOptions: { label: string; value: any }[] = null; - dataTypes: { label: string; value: any }[] = []; + formAdditionalOptions: SimpleDropdownValue[] = null; + dataTypes: SimpleDropdownValue[] = []; constructor( @Inject(MAT_DIALOG_DATA) @@ -74,14 +74,14 @@ export class ConfigFieldComponent { }, private dialogRef: MatDialogRef, private fb: FormBuilder, - @Inject(DefaultDatatype) dataTypes: DefaultDatatype[], + @Inject(DefaultDatatype) allDataTypes: DefaultDatatype[], private configurableEnumService: ConfigurableEnumService, private entityRegistry: EntityRegistry, ) { this.entitySchemaField = data.entitySchemaField; this.initSettings(); - this.initAvailableDatatypes(dataTypes); + this.initAvailableDatatypes(allDataTypes); } private initSettings() { @@ -147,8 +147,8 @@ export class ConfigFieldComponent { value: d.dataType, })); } - objectToLabel = (v: { label: string }) => v?.label; - objectToValue = (v: { value: string }) => v?.value; + objectToLabel = (v: SimpleDropdownValue) => v?.label; + objectToValue = (v: SimpleDropdownValue) => v?.value; private updateDataTypeAdditional(dataType: string) { if (dataType === ConfigurableEnumDatatype.dataType) { @@ -187,3 +187,5 @@ export class ConfigFieldComponent { this.dialogRef.close(updatedEntitySchema); } } + +type SimpleDropdownValue = { label: string; value: string }; diff --git a/src/styles/variables/_sizes.scss b/src/styles/variables/_sizes.scss index 35667c7022..be5e481ff5 100644 --- a/src/styles/variables/_sizes.scss +++ b/src/styles/variables/_sizes.scss @@ -22,3 +22,5 @@ $margin-main-view-right: 28px; $margin-main-view-left: 28px; $margin-main-view-top: 16px; $margin-main-view-bottom: 64px; + +$form-group-min-width: 250px; From 8501529f526a6e71f2dc4b10aa7053a2348f7f1f Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 14:30:23 +0100 Subject: [PATCH 016/132] fix(core): support "required" indicator for custom form-controls --- .../custom-form-control.directive.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/core/common-components/basic-autocomplete/custom-form-control.directive.ts b/src/app/core/common-components/basic-autocomplete/custom-form-control.directive.ts index 48263da39b..3edaabf9b8 100644 --- a/src/app/core/common-components/basic-autocomplete/custom-form-control.directive.ts +++ b/src/app/core/common-components/basic-autocomplete/custom-form-control.directive.ts @@ -4,6 +4,7 @@ import { FormGroupDirective, NgControl, NgForm, + Validators, } from "@angular/forms"; import { MatFormFieldControl } from "@angular/material/form-field"; import { @@ -28,7 +29,16 @@ export abstract class CustomFormControlDirective // eslint-disable-next-line @angular-eslint/no-input-rename @Input("aria-describedby") userAriaDescribedBy: string; @Input() placeholder: string; - @Input() required = false; + + @Input() + get required() { + return this._required; + } + set required(req: boolean) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + private _required = false; abstract inputElement: { _elementRef: ElementRef }; stateChanges = new Subject(); @@ -139,5 +149,13 @@ export abstract class CustomFormControlDirective this.errorState = newState; this.stateChanges.next(); } + + if (control.hasValidator(Validators.required)) { + this.required = true; + this.stateChanges.next(); + } else if (this.required) { + this.required = false; + this.stateChanges.next(); + } } } From dd4800c219ef3e9bceac39712f027120dfc6f640 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 15:18:28 +0100 Subject: [PATCH 017/132] form validity for entity schema field --- .../config-entity-form.component.ts | 3 +- .../config-field/config-field.component.html | 4 +- .../config-field/config-field.component.ts | 54 +++++++++++++------ .../config-field/config-field.stories.ts | 36 ++++++------- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index e190c919a8..e3cd82bd04 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -14,7 +14,8 @@ import { import { EntitySchema } from "../../entity/schema/entity-schema"; import { EntitySchemaField } from "../../entity/schema/entity-schema-field"; -type EntitySchemaField_withId = EntitySchemaField & { id: string }; +// TODO: adapt EntitySchemaField interface? +export type EntitySchemaField_withId = EntitySchemaField & { id: string }; @Component({ selector: "app-config-entity-form", diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html index b903d001e5..7b3c240546 100644 --- a/src/app/core/config-ui/config-field/config-field.component.html +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -69,12 +69,12 @@

Configure Field "{{ entitySchemaField.label }}"

Type Details diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index 3d5ba23842..cc71a50e4d 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -1,4 +1,10 @@ -import { Component, Inject, Input } from "@angular/core"; +import { + Component, + Inject, + Input, + OnChanges, + SimpleChanges, +} from "@angular/core"; import { Entity } from "../../entity/model/entity"; import { MAT_DIALOG_DATA, @@ -15,6 +21,7 @@ import { FormGroup, FormsModule, ReactiveFormsModule, + Validators, } from "@angular/forms"; import { NgIf } from "@angular/common"; import { EntitySchemaField } from "../../entity/schema/entity-schema-field"; @@ -57,14 +64,14 @@ import { EntityRegistry } from "../../entity/database-entity.decorator"; BasicAutocompleteComponent, ], }) -export class ConfigFieldComponent { +export class ConfigFieldComponent implements OnChanges { @Input() entitySchemaField: EntitySchemaField & { id?: string }; // TODO: add id / key to EntitySchemaField for easier handling? form: FormGroup; formLabelShort: FormControl; useShortLabel: boolean; formAdditional: FormControl; - formAdditionalOptions: SimpleDropdownValue[] = null; + typeAdditionalOptions: SimpleDropdownValue[]; dataTypes: SimpleDropdownValue[] = []; constructor( @@ -84,21 +91,30 @@ export class ConfigFieldComponent { this.initAvailableDatatypes(allDataTypes); } + ngOnChanges(changes: SimpleChanges): void { + if (changes.entitySchemaField) { + this.initSettings(); + } + } + private initSettings() { this.formLabelShort = this.fb.control(this.entitySchemaField.labelShort); this.formAdditional = this.fb.control(this.entitySchemaField.additional); this.form = this.fb.group({ - label: [this.entitySchemaField.label], + label: [this.entitySchemaField.label, Validators.required], labelShort: this.formLabelShort, description: [this.entitySchemaField.description], - id: this.fb.control({ - value: this.entitySchemaField.id, - disabled: this.entitySchemaField.id !== null, // disabled if not newly created field - }), - dataType: [this.entitySchemaField.dataType], - additional: [this.entitySchemaField.additional], + id: this.fb.control( + { + value: this.entitySchemaField.id, + disabled: this.entitySchemaField.id !== null, // disabled if not newly created field + }, + [Validators.required], + ), + dataType: [this.entitySchemaField.dataType, Validators.required], + additional: this.formAdditional, // TODO: remove "innerDataType" completely - the UI can only support very specific multi-valued types anyway // TODO add a datatype "alias" for enum-array @@ -125,7 +141,7 @@ export class ConfigFieldComponent { this.useShortLabel = useShortLabel; if (!this.useShortLabel) { - this.formLabelShort.setValue(null); + this.formLabelShort.setValue(undefined); this.formLabelShort.disable(); } @@ -152,24 +168,27 @@ export class ConfigFieldComponent { private updateDataTypeAdditional(dataType: string) { if (dataType === ConfigurableEnumDatatype.dataType) { - this.formAdditionalOptions = this.configurableEnumService + this.typeAdditionalOptions = this.configurableEnumService .listEnums() .map((x) => ({ label: Entity.extractEntityIdFromId(x), value: Entity.extractEntityIdFromId(x), })); + this.formAdditional.addValidators(Validators.required); // TODO allow new enum creation // TODO preview the options within the selected enum (and allow to edit the enum options?) } else if ( dataType === EntityDatatype.dataType || dataType === EntityArrayDatatype.dataType ) { - this.formAdditionalOptions = this.entityRegistry + this.typeAdditionalOptions = this.entityRegistry .getEntityTypes(true) .map((x) => ({ label: x.value.label, value: x.value.ENTITY_TYPE })); + this.formAdditional.addValidators(Validators.required); } else { - this.form.get("additional").setValue(null); - this.formAdditionalOptions = null; + this.formAdditional.setValue(undefined); + this.formAdditional.removeValidators(Validators.required); + this.typeAdditionalOptions = undefined; } // hasInnerType: [ArrayDatatype.dataType].includes(d.dataType), @@ -178,6 +197,11 @@ export class ConfigFieldComponent { } save() { + this.form.markAllAsTouched(); + if (this.form.invalid) { + return; + } + const updatedEntitySchema = Object.assign( {}, this.entitySchemaField, diff --git a/src/app/core/config-ui/config-field/config-field.stories.ts b/src/app/core/config-ui/config-field/config-field.stories.ts index 090c116098..000e646168 100644 --- a/src/app/core/config-ui/config-field/config-field.stories.ts +++ b/src/app/core/config-ui/config-field/config-field.stories.ts @@ -7,20 +7,8 @@ import { import { StorybookBaseModule } from "../../../utils/storybook-base.module"; import { importProvidersFrom } from "@angular/core"; import { ConfigFieldComponent } from "./config-field.component"; -import { Child } from "../../../child-dev-project/children/model/child"; -import { FormFieldConfig } from "../../common-components/entity-form/entity-form/FormConfig"; -import { MAT_DIALOG_DATA } from "@angular/material/dialog"; - -const sampleFieldConfig: FormFieldConfig = { - id: "name", - edit: "EditText", - view: "DisplayText", - forTable: false, - label: "Name", - validators: { - required: true, - }, -}; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { EntitySchemaField_withId } from "../config-entity-form/config-entity-form.component"; export default { title: "Core/Admin UI/Config Field", @@ -35,10 +23,10 @@ export default { { provide: MAT_DIALOG_DATA, useValue: { - entityType: Child, - formFieldConfig: sampleFieldConfig, + entitySchemaField: { id: null }, }, }, + { provide: MatDialogRef, useValue: null }, ], }), ], @@ -49,5 +37,17 @@ const Template: StoryFn = (args) => ({ props: args, }); -export const Primary = Template.bind({}); -Primary.args = {}; +export const EditExisting = Template.bind({}); +EditExisting.args = { + entitySchemaField: { + id: "name", + dataType: "string", + label: "Firstname", + description: "abc", + } as EntitySchemaField_withId, +}; + +export const CreateNew = Template.bind({}); +CreateNew.args = { + entitySchemaField: { id: null } as EntitySchemaField_withId, +}; From 91c3f767b57bceca09c145b41fbecf0a48cb7f4a Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 17:07:43 +0100 Subject: [PATCH 018/132] drag&drop tweaks --- .../config-entity-form.component.html | 49 ++++++------ .../config-entity-form.component.scss | 74 ++++++++++++++----- .../config-entity-form.component.ts | 10 +-- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index 02c60638e6..ebc00add2f 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -1,5 +1,5 @@ - +
Group Title - + + +
+
+ + +
+
-
diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss index bba46d904d..5e5d1d6f62 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -1,15 +1,46 @@ @use "src/styles/variables/colors"; @use "src/styles/variables/sizes"; +@use "src/styles/mixins/grid-layout"; -.admin-form-field:hover { - background-color: colors.$grey-transparent; +$toolbar-width: 300px; +.toolbar { + width: $toolbar-width; + padding: sizes.$small; +} + +.admin-grid-layout { + @include grid-layout.adaptive( + $min-block-width: calc(#{sizes.$form-group-min-width} + 28px + 2*2*#{sizes.$small}), + $max-screen-width: 414px + ); } + +.fields-group-list { + border: dashed 1px #ccc; + border-radius: 4px; + overflow: hidden; + display: block; + padding: 0 sizes.$small; +} +.drop-list { + min-height: 60px; + height: 99%; +} + .admin-form-field { padding: sizes.$small; - margin: sizes.$small; + margin: sizes.$small auto; border: dotted 1px colors.$accent; border-radius: sizes.$x-small; + position: relative; + overflow: hidden; + + // draggable item must not be wider than toolbar, otherwise it cannot be dropped there due to cdkDragBoundary + max-width: $toolbar-width; } +.admin-form-field:hover { + background-color: colors.$grey-transparent; +} .drag-handle { color: colors.$accent; @@ -18,27 +49,32 @@ text-align: center; } -.dummy-form-field ::ng-deep input, .dummy-form-field ::ng-deep button { pointer-events: none } -.dummy-form-field ::ng-deep mat-form-field { - max-width: calc(#{sizes.$form-group-min-width} - 2*2*#{sizes.$small}); -} +.field-edit-button { + visibility: hidden; + background: white !important; + z-index: 10; + padding: 1.5em; -.toolbar { - width: 300px; - padding: sizes.$small; + position: absolute; + // center within parent: + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + width: fit-content; } - -.drop-list { - min-height: 60px; - height: 99%; +.admin-form-field:hover .field-edit-button { + visibility: visible; } -.fields-group-list { - border: dashed 1px #ccc; - border-radius: 4px; - overflow: hidden; - display: block; + +.dummy-form-field { width: 100% } +.dummy-form-field ::ng-deep input, .dummy-form-field ::ng-deep button { pointer-events: none } +.dummy-form-field ::ng-deep mat-form-field { + width: 100% } + .drop-area-hint { text-align: center; padding: sizes.$small; diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index e3cd82bd04..70e74901db 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -151,14 +151,8 @@ export class ConfigEntityFormComponent implements OnChanges { } removeGroup(i: number) { - this.fieldGroups.splice(i, 1); - } - - private async createNewField(fieldGroup: any[], index: number) { - const newField = { id: null }; - await this.openFieldConfig(newField, fieldGroup); - - fieldGroup.splice(index, 0, newField); + const [removedFieldGroup] = this.fieldGroups.splice(i, 1); + this.availableFields.push(...removedFieldGroup.fields); } } From 8dc785bb492445f876e4593fe1822cb0d31c4f7c Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 17:56:56 +0100 Subject: [PATCH 019/132] drag&drop design --- .../config-entity-form.component.html | 45 ++++++++++++++----- .../config-entity-form.component.scss | 14 ++++++ .../config-entity-form.component.ts | 18 ++++++++ src/app/core/config-ui/config-ui.module.ts | 4 +- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html index ebc00add2f..01dff86ca4 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.html +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.html @@ -1,5 +1,6 @@ - - +
+ +
drop here to create new field group +
- +
- - -
+ + hidden fields
drag & drop to / from here +
@@ -113,8 +125,19 @@ cdkDragHandle > -
{{ f.label }}
+
+ {{ f.label }} + +
-
- - + + +
diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss index 5e5d1d6f62..57a7f0f32c 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.scss @@ -1,11 +1,16 @@ @use "src/styles/variables/colors"; @use "src/styles/variables/sizes"; @use "src/styles/mixins/grid-layout"; +@use "@angular/material/core/style/elevation" as mat-elevation; $toolbar-width: 300px; .toolbar { width: $toolbar-width; padding: sizes.$small; + + @include mat-elevation.elevation(4); + border-bottom-left-radius: 0; + border-top-right-radius: 0; } .admin-grid-layout { @@ -42,6 +47,12 @@ $toolbar-width: 300px; background-color: colors.$grey-transparent; } +.admin-form-field-new, .admin-form-field-new:hover { + border-color: green; + background-color: rgba(0, 255, 0, 0.05); + font-style: italic; +} + .drag-handle { color: colors.$accent; cursor: move; @@ -67,6 +78,9 @@ $toolbar-width: 300px; .admin-form-field:hover .field-edit-button { visibility: visible; } +.field-edit-button-small { + left: unset; +} .dummy-form-field { width: 100% } .dummy-form-field ::ng-deep input, .dummy-form-field ::ng-deep button { pointer-events: none } diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index 70e74901db..69f5b760ec 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -85,6 +85,11 @@ export class ConfigEntityFormComponent implements OnChanges { .afterClosed() .subscribe((updatedFieldSchema: EntitySchemaField_withId) => { if (!updatedFieldSchema) { + // canceled + if (!field.id) { + // remove newly created field that was canceled + fieldsArray.splice(fieldsArray.indexOf(field), 1); + } return; } @@ -121,6 +126,11 @@ export class ConfigEntityFormComponent implements OnChanges { drop(event: CdkDragDrop) { const item = event.previousContainer.data[event.previousIndex]; if (item.id === null) { + if (event.container.data === this.availableFields) { + // don't add new field to the disabled fields + return; + } + const newField = { id: null }; event.container.data.splice(event.currentIndex, 0, newField); this.openFieldConfig(newField, event.container.data); @@ -141,6 +151,14 @@ export class ConfigEntityFormComponent implements OnChanges { event.currentIndex, ); } + + if ( + event.container.data === this.availableFields && + event.currentIndex === 0 + ) { + // ensure "create new field" is always first + moveItemInArray(event.container.data, event.currentIndex, 1); + } } dropNewGroup(event: CdkDragDrop) { diff --git a/src/app/core/config-ui/config-ui.module.ts b/src/app/core/config-ui/config-ui.module.ts index a9e57f2439..3fb571c9fe 100644 --- a/src/app/core/config-ui/config-ui.module.ts +++ b/src/app/core/config-ui/config-ui.module.ts @@ -21,7 +21,7 @@ import { MatInputModule } from "@angular/material/input"; import { MatButtonModule } from "@angular/material/button"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatTooltipModule } from "@angular/material/tooltip"; -import { MatSidenavModule } from "@angular/material/sidenav"; +import { MatCardModule } from "@angular/material/card"; const routes: Routes = [ { @@ -55,7 +55,7 @@ const routes: Routes = [ FontAwesomeModule, MatTooltipModule, CdkDragHandle, - MatSidenavModule, + MatCardModule, ], exports: [RouterModule, ConfigEntityComponent], declarations: [ConfigEntityComponent, ConfigEntityFormComponent], From 8788d289954944991b25734230d0872faeff5b2d Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Wed, 8 Nov 2023 18:24:08 +0100 Subject: [PATCH 020/132] test fixes and cleanups --- .../config-entity-form.component.spec.ts | 25 +++++++++++--- .../config-entity.component.spec.ts | 17 +++++++--- .../config-field.component.spec.ts | 34 +++++++++++++++---- .../entity-details.component.spec.ts | 2 +- .../form/form.component.spec.ts | 2 +- .../ui/routed-view/routed-view.component.html | 1 - .../ui/routed-view/routed-view.component.scss | 0 7 files changed, 62 insertions(+), 19 deletions(-) delete mode 100644 src/app/core/ui/routed-view/routed-view.component.html delete mode 100644 src/app/core/ui/routed-view/routed-view.component.scss diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts index fbb383730d..3767931a22 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.spec.ts @@ -1,21 +1,36 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ConfigEntityFormComponent } from './config-entity-form.component'; +import { ConfigEntityFormComponent } from "./config-entity-form.component"; +import { ConfigUiModule } from "../config-ui.module"; +import { CoreTestingModule } from "../../../utils/core-testing.module"; +import { EntityFormService } from "../../common-components/entity-form/entity-form.service"; +import { MatDialog } from "@angular/material/dialog"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; -describe('ConfigEntityFormComponent', () => { +describe("ConfigEntityFormComponent", () => { let component: ConfigEntityFormComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ - declarations: [ConfigEntityFormComponent] + imports: [ConfigUiModule, CoreTestingModule, FontAwesomeTestingModule], + providers: [ + { + provide: EntityFormService, + useValue: jasmine.createSpyObj(["extendFormFieldConfig"]), + }, + { + provide: MatDialog, + useValue: jasmine.createSpyObj(["open"]), + }, + ], }); fixture = TestBed.createComponent(ConfigEntityFormComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { + it("should create", () => { expect(component).toBeTruthy(); }); }); diff --git a/src/app/core/config-ui/config-entity/config-entity.component.spec.ts b/src/app/core/config-ui/config-entity/config-entity.component.spec.ts index f1bff7ed26..dcba600365 100644 --- a/src/app/core/config-ui/config-entity/config-entity.component.spec.ts +++ b/src/app/core/config-ui/config-entity/config-entity.component.spec.ts @@ -1,21 +1,28 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ConfigEntityComponent } from './config-entity.component'; +import { ConfigEntityComponent } from "./config-entity.component"; +import { ConfigUiModule } from "../config-ui.module"; +import { CoreTestingModule } from "../../../utils/core-testing.module"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; -describe('ConfigEntityComponent', () => { +describe("ConfigEntityComponent", () => { let component: ConfigEntityComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ - declarations: [ConfigEntityComponent] + imports: [ConfigUiModule, CoreTestingModule, FontAwesomeTestingModule], }); fixture = TestBed.createComponent(ConfigEntityComponent); component = fixture.componentInstance; + + component.entityType = Child.ENTITY_TYPE; + fixture.detectChanges(); }); - it('should create', () => { + it("should create", () => { expect(component).toBeTruthy(); }); }); diff --git a/src/app/core/config-ui/config-field/config-field.component.spec.ts b/src/app/core/config-ui/config-field/config-field.component.spec.ts index e07a584d6d..21b33c64b3 100644 --- a/src/app/core/config-ui/config-field/config-field.component.spec.ts +++ b/src/app/core/config-ui/config-field/config-field.component.spec.ts @@ -1,21 +1,43 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ConfigFieldComponent } from "./config-field.component"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { CoreTestingModule } from "../../../utils/core-testing.module"; +import { EntitySchemaField_withId } from "../config-entity-form/config-entity-form.component"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { ConfigFieldComponent } from './config-field.component'; - -describe('ConfigFieldComponent', () => { +describe("ConfigFieldComponent", () => { let component: ConfigFieldComponent; let fixture: ComponentFixture; + let testSchemaField: EntitySchemaField_withId; + beforeEach(() => { + testSchemaField = { + id: "test", + }; + TestBed.configureTestingModule({ - declarations: [ConfigFieldComponent] + imports: [ + ConfigFieldComponent, + CoreTestingModule, + FontAwesomeTestingModule, + NoopAnimationsModule, + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { entitySchemaField: testSchemaField }, + }, + { provide: MatDialogRef, useValue: null }, + ], }); fixture = TestBed.createComponent(ConfigFieldComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { + it("should create", () => { expect(component).toBeTruthy(); }); }); diff --git a/src/app/core/entity-details/entity-details/entity-details.component.spec.ts b/src/app/core/entity-details/entity-details/entity-details.component.spec.ts index 3399393f25..e76003429d 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.spec.ts +++ b/src/app/core/entity-details/entity-details/entity-details.component.spec.ts @@ -92,7 +92,7 @@ describe("EntityDetailsComponent", () => { component.ngOnChanges(simpleChangesFor(component, "id")); tick(); - component.panelsComponents.forEach((p) => + component.panels.forEach((p) => p.components.forEach((c) => { const panelConfig = c.config as PanelConfig; expect(panelConfig.entity).toEqual(testChild); diff --git a/src/app/core/entity-details/form/form.component.spec.ts b/src/app/core/entity-details/form/form.component.spec.ts index 683013ab8e..a472b32e82 100644 --- a/src/app/core/entity-details/form/form.component.spec.ts +++ b/src/app/core/entity-details/form/form.component.spec.ts @@ -119,7 +119,7 @@ describe("FormComponent", () => { component.ngOnInit(); - expect(component._cols).toEqual([ + expect(component.columns).toEqual([ [ { id: "fieldWithDefinition", diff --git a/src/app/core/ui/routed-view/routed-view.component.html b/src/app/core/ui/routed-view/routed-view.component.html deleted file mode 100644 index 57632d64fc..0000000000 --- a/src/app/core/ui/routed-view/routed-view.component.html +++ /dev/null @@ -1 +0,0 @@ -

routed-view works!

diff --git a/src/app/core/ui/routed-view/routed-view.component.scss b/src/app/core/ui/routed-view/routed-view.component.scss deleted file mode 100644 index e69de29bb2..0000000000 From cc9b5845ead7508db0d5063387e78016de30cccd Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Mon, 13 Nov 2023 09:45:21 +0100 Subject: [PATCH 021/132] fix invalid hidden "additional" field --- src/app/core/config-ui/config-field/config-field.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/config-ui/config-field/config-field.component.ts b/src/app/core/config-ui/config-field/config-field.component.ts index cc71a50e4d..398d8c3c42 100644 --- a/src/app/core/config-ui/config-field/config-field.component.ts +++ b/src/app/core/config-ui/config-field/config-field.component.ts @@ -186,8 +186,8 @@ export class ConfigFieldComponent implements OnChanges { .map((x) => ({ label: x.value.label, value: x.value.ENTITY_TYPE })); this.formAdditional.addValidators(Validators.required); } else { - this.formAdditional.setValue(undefined); this.formAdditional.removeValidators(Validators.required); + this.formAdditional.setValue(undefined); this.typeAdditionalOptions = undefined; } From 09bc2bdb45d1e7269fe32135f4081a3b16359742 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Mon, 13 Nov 2023 13:19:38 +0100 Subject: [PATCH 022/132] auto-generate initial field id --- .../entity-form/unique-id-validator.ts | 15 ++++++ .../config-entity-form.component.ts | 5 +- .../config-field/config-field.component.html | 28 +++++++---- .../config-field.component.spec.ts | 50 ++++++++++++++++++- .../config-field/config-field.component.ts | 47 ++++++++++++++--- 5 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 src/app/core/common-components/entity-form/unique-id-validator.ts diff --git a/src/app/core/common-components/entity-form/unique-id-validator.ts b/src/app/core/common-components/entity-form/unique-id-validator.ts new file mode 100644 index 0000000000..5a992bb85d --- /dev/null +++ b/src/app/core/common-components/entity-form/unique-id-validator.ts @@ -0,0 +1,15 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; + +export function uniqueIdValidator(existingIds: string[]): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + + if (existingIds.some((id) => id === value)) { + return { + uniqueId: $localize`:form field validation error:id already in use`, + }; + } + + return null; + }; +} diff --git a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts index 69f5b760ec..d57d412e3f 100644 --- a/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts +++ b/src/app/core/config-ui/config-entity-form/config-entity-form.component.ts @@ -80,7 +80,10 @@ export class ConfigEntityFormComponent implements OnChanges { .open(ConfigFieldComponent, { width: "99%", maxHeight: "90vh", - data: { entitySchemaField: schemaField }, + data: { + entitySchemaField: schemaField, + entitySchema: this.entitySchema, + }, }) .afterClosed() .subscribe((updatedFieldSchema: EntitySchemaField_withId) => { diff --git a/src/app/core/config-ui/config-field/config-field.component.html b/src/app/core/config-ui/config-field/config-field.component.html index 7b3c240546..0ea3ea1beb 100644 --- a/src/app/core/config-ui/config-field/config-field.component.html +++ b/src/app/core/config-ui/config-field/config-field.component.html @@ -31,11 +31,13 @@

Configure Field "{{ entitySchemaField.label }}"

- - Description + + Description +