From 3b4168b92b9ef214b66e09e594fb77bec2a49a43 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 13 Dec 2023 16:51:58 +0100 Subject: [PATCH] feat(admin): Basic Admin UI to add and edit fields of entity and details view (#2057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #2047 , closes #2048 --------- This functionality has been developed for the project “codo”. codo is developed under the projects “Landungsbrücken – Patenschaften in Hamburg stärken” and “openTransfer Patenschaften”. It is funded through the program “Menschen stärken Menschen” by the German Federal Ministry of Family Affairs, Senior Citizens, Women and Youth. More information at https://github.com/codo-mentoring “Landungsbrücken – Patenschaften in Hamburg stärken” is a project of BürgerStiftung Hamburg in cooperation with the Mentor.Ring Hamburg. With a mix of networking opportunities, capacity building and financial support the project strengthens Hamburg’s scene of mentoring projects since its founding in 2016. The “Stiftung Bürgermut” foundation since 2007 supports the digital and real exchange of experiences and connections of active citizens. Within the federal program “Menschen stärken Menschen” the foundation as part of its program “openTransfer Patenschaften” offers support services for connecting, spreading and upskilling mentoring organisations across Germany. Diese Funktion wurde entwickelt für das Projekt codo. codo wird entwickelt im Rahmen der Projekte Landungsbrücken – Patenschaften in Hamburg stärken und openTransfer Patenschaften. Er ist gefördert durch das Bundesprogramm Menschen stärken Menschen des Bundesministeriums für Familie, Senioren, Frauen und Jugend. Mehr Informationen unter https://github.com/codo-mentoring “Landungsbrücken – Patenschaften in Hamburg stärken” ist ein Projekt der BürgerStiftung Hamburg in Kooperation mit dem Mentor.Ring Hamburg. Mit einer Mischung aus Vernetzungsangeboten, Qualifizierungsmaßnahmen und finanzieller Förderung stärkt das Projekt die Hamburger Szene der Patenschaftsprojekte seit der Gründung im Jahr 2016. Die Stiftung Bürgermut fördert seit 2007 den digitalen und realen Erfahrungsaustausch und die Vernetzung von engagierten Bürger:innen. Innerhalb des Bundesprogramms „Menschen stärken Menschen” bietet die Stiftung im Rahmen ihres Programms openTransfer Patenschaften Unterstützungsleistungen zur Vernetzung, Verbreitung und Qualifizierung von Patenschafts- und Mentoringorganisationen bundesweit. Co-authored-by: codo-mentoring <117934638+codo-mentoring@users.noreply.github.com> Co-authored-by: Simon --- src/app/app.module.ts | 8 +- src/app/app.routing.ts | 16 +- .../add-day-attendance.component.ts | 2 +- .../attendance-manager.component.ts | 2 +- .../attendance/attendance.module.ts | 12 +- .../model/event-attendance.datatype.spec.ts | 32 ++ .../model/event-attendance.datatype.ts | 16 + .../attendance/model/event-attendance.ts | 2 + .../children-list/children-list.component.ts | 2 +- src/app/child-dev-project/notes/model/note.ts | 3 +- .../notes-manager/notes-manager.component.ts | 2 +- .../admin-entity-field.component.html | 188 +++++++++++ .../admin-entity-field.component.scss | 8 + .../admin-entity-field.component.spec.ts | 211 ++++++++++++ .../admin-entity-field.component.ts | 309 ++++++++++++++++++ .../admin-entity-field.stories.ts | 56 ++++ .../admin-entity-form.component.html | 124 +++++++ .../admin-entity-form.component.scss | 125 +++++++ .../admin-entity-form.component.spec.ts | 181 ++++++++++ .../admin-entity-form.component.ts | 203 ++++++++++++ ...dmin-entity-panel-component.component.html | 7 + ...dmin-entity-panel-component.component.scss | 0 ...n-entity-panel-component.component.spec.ts | 27 ++ .../admin-entity-panel-component.component.ts | 16 + .../admin-entity-details.component.html | 88 +++++ .../admin-entity-details.component.scss | 36 ++ .../admin-entity-details.component.spec.ts | 171 ++++++++++ .../admin-entity-details.component.ts | 158 +++++++++ .../admin-entity-details.stories.ts | 34 ++ .../admin-section-header.component.html | 15 + .../admin-section-header.component.scss | 5 + .../admin-section-header.component.spec.ts | 59 ++++ .../admin-section-header.component.ts | 65 ++++ .../core/admin/admin-entity.service.spec.ts | 16 + src/app/core/admin/admin-entity.service.ts | 27 ++ src/app/core/admin/admin.module.ts | 35 ++ src/app/core/admin/admin.routing.ts | 82 +++++ .../admin/admin/admin.component.html | 25 +- .../admin/admin/admin.component.scss | 2 +- .../admin/admin/admin.component.spec.ts | 10 +- .../admin/admin/admin.component.ts | 25 +- .../admin/backup}/backup.service.spec.ts | 8 +- .../admin/backup}/backup.service.ts | 6 +- .../basic-datatypes/array/array.datatype.ts | 2 +- .../boolean/boolean.datatype.ts | 3 +- .../configurable-enum.datatype.ts | 3 +- .../configurable-enum.service.ts | 4 + .../enum-dropdown.component.html | 21 +- .../enum-dropdown.component.scss | 6 - .../enum-dropdown/enum-dropdown.component.ts | 4 + .../date-only/date-only.datatype.ts | 17 +- .../date-with-age/date-with-age.datatype.ts | 17 +- .../date/date.datatype.spec.ts | 13 + .../basic-datatypes/date/date.datatype.ts | 19 +- .../entity-array/entity-array.datatype.ts | 3 +- .../basic-datatypes/entity/entity.datatype.ts | 4 +- .../core/basic-datatypes/map/map.datatype.ts | 2 +- .../basic-datatypes/month/month.datatype.ts | 15 +- .../basic-datatypes/number/number.datatype.ts | 4 +- .../schema-embed.datatype.spec.ts | 41 ++- .../schema-embed/schema-embed.datatype.ts | 19 +- .../string/long-text.datatype.ts | 13 + .../basic-datatypes/string/string.datatype.ts | 3 +- .../entity-field-edit.component.spec.ts | 9 + .../entity-form/entity-form.component.scss | 3 +- .../entity-form/unique-id-validator.ts | 15 + .../entity-type-label-pipe.spec.ts | 33 -- .../entity-type-label.pipe.spec.ts | 32 +- .../view-title/view-title.component.html | 2 +- .../view-title/view-title.component.scss | 2 + src/app/core/config/config-fix.ts | 57 +--- src/app/core/config/config.ts | 6 + .../config/dynamic-routing/router.service.ts | 11 +- src/app/core/core.module.ts | 8 +- .../dashboard/dashboard.component.ts | 2 +- src/app/core/demo-data/demo-data-generator.ts | 2 +- .../entity-details/EntityDetailsConfig.ts | 2 +- .../entity-details.component.html | 17 +- .../entity-details.component.ts | 10 +- .../entity-details/form/form.component.ts | 9 +- .../entity-list/entity-list.component.ts | 2 +- .../core/entity/database-entity.decorator.ts | 19 +- .../default-datatype/default.datatype.ts | 8 + .../entity-actions/entity-actions.service.ts | 11 +- .../entity-delete.service.spec.ts | 7 + src/app/core/entity/entity-config.service.ts | 8 + .../entity-mapper.service.spec.ts | 12 - src/app/core/entity/model/entity.ts | 4 - .../model/update-metadata.datatype.spec.ts | 23 ++ .../entity/model/update-metadata.datatype.ts | 19 ++ src/app/core/entity/model/update-metadata.ts | 4 +- .../core/entity/schema/entity-schema-field.ts | 8 + .../schema/entity-schema.service.spec.ts | 78 +++-- .../download-service/download.service.spec.ts | 14 +- .../download-service/download.service.ts | 2 +- .../import-entity-type.component.ts | 14 +- .../import-entity-type.stories.ts | 3 +- .../core/import/import/import.component.ts | 2 +- .../ability/ability.service.spec.ts | 7 + .../permission-guard/user-role.guard.spec.ts | 33 +- .../permission-guard/user-role.guard.ts | 60 +++- .../support/support/support.component.spec.ts | 2 +- .../core/support/support/support.component.ts | 2 +- .../ui/routed-view/routed-view.component.ts | 2 +- .../ui/ui/ui.component.global-styles.scss | 5 + src/app/features/admin/admin.module.ts | 17 - .../coming-soon/coming-soon.component.ts | 3 +- .../config-import-parser.service.spec.ts | 19 -- .../config-import-parser.service.ts | 18 +- .../config-import/config-import.component.ts | 2 +- .../conflict-resolution-list.component.ts | 2 +- src/app/features/file/file.datatype.ts | 4 +- .../location/location.datatype.spec.ts | 12 + .../features/location/location.datatype.ts | 14 +- .../markdown-page/markdown-page.component.ts | 2 +- .../matching-entities.component.ts | 2 +- .../reporting/reporting.component.ts | 2 +- .../time-interval.datatype.ts | 3 +- .../todos/todo-list/todo-list.component.ts | 2 +- src/app/route-target.ts | 9 + .../generate-id-from-label.spec.ts | 22 ++ .../generate-id-from-label.ts | 13 + src/app/utils/mocked-testing.module.ts | 2 +- src/app/utils/storybook-base.module.ts | 8 +- src/app/utils/utils.ts | 2 +- src/styles/variables/_sizes.scss | 2 + 126 files changed, 2980 insertions(+), 381 deletions(-) create mode 100644 src/app/child-dev-project/attendance/model/event-attendance.datatype.spec.ts create mode 100644 src/app/child-dev-project/attendance/model/event-attendance.datatype.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.html create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.scss create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.spec.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.stories.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.html create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.scss create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.spec.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.html create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.scss create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.spec.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.html create mode 100644 src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.scss create mode 100644 src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.spec.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.stories.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.html create mode 100644 src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.scss create mode 100644 src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.spec.ts create mode 100644 src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.ts create mode 100644 src/app/core/admin/admin-entity.service.spec.ts create mode 100644 src/app/core/admin/admin-entity.service.ts create mode 100644 src/app/core/admin/admin.module.ts create mode 100644 src/app/core/admin/admin.routing.ts rename src/app/{features => core}/admin/admin/admin.component.html (78%) rename src/app/{features => core}/admin/admin/admin.component.scss (90%) rename src/app/{features => core}/admin/admin/admin.component.spec.ts (91%) rename src/app/{features => core}/admin/admin/admin.component.ts (84%) rename src/app/{features/admin/services => core/admin/backup}/backup.service.spec.ts (90%) rename src/app/{features/admin/services => core/admin/backup}/backup.service.ts (90%) create mode 100644 src/app/core/basic-datatypes/string/long-text.datatype.ts create mode 100644 src/app/core/common-components/entity-form/unique-id-validator.ts delete mode 100644 src/app/core/common-components/entity-type-label/entity-type-label-pipe.spec.ts create mode 100644 src/app/core/entity/model/update-metadata.datatype.spec.ts create mode 100644 src/app/core/entity/model/update-metadata.datatype.ts delete mode 100644 src/app/features/admin/admin.module.ts create mode 100644 src/app/route-target.ts create mode 100644 src/app/utils/generate-id-from-label/generate-id-from-label.spec.ts create mode 100644 src/app/utils/generate-id-from-label/generate-id-from-label.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 944413b690..18e81e3505 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -71,7 +71,6 @@ import { import { AttendanceModule } from "./child-dev-project/attendance/attendance.module"; import { NotesModule } from "./child-dev-project/notes/notes.module"; import { SchoolsModule } from "./child-dev-project/schools/schools.module"; -import { ConflictResolutionModule } from "./features/conflict-resolution/conflict-resolution.module"; import { HistoricalDataModule } from "./features/historical-data/historical-data.module"; import { MatchingEntitiesModule } from "./features/matching-entities/matching-entities.module"; import { ProgressDashboardWidgetModule } from "./features/dashboard-widgets/progress-dashboard-widget/progress-dashboard-widget.module"; @@ -87,10 +86,9 @@ import { ImportModule } from "./core/import/import.module"; import { ShortcutDashboardWidgetModule } from "./features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard-widget.module"; import { EntityCountDashboardWidgetModule } from "./features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard-widget.module"; import { BirthdayDashboardWidgetModule } from "./features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard-widget.module"; -import { ConfigSetupModule } from "./features/config-setup/config-setup.module"; import { MarkdownPageModule } from "./features/markdown-page/markdown-page.module"; -import { AdminModule } from "./features/admin/admin.module"; import { LoginStateSubject } from "./core/session/session-type"; +import { AdminModule } from "./core/admin/admin.module"; /** * Main entry point of the application. @@ -123,10 +121,7 @@ import { LoginStateSubject } from "./core/session/session-type"; NotesModule, SchoolsModule, // feature module - ConflictResolutionModule, - AdminModule, ImportModule, - ConfigSetupModule, FileModule, MarkdownPageModule, HistoricalDataModule, @@ -138,6 +133,7 @@ import { LoginStateSubject } from "./core/session/session-type"; BirthdayDashboardWidgetModule, ReportingModule, TodosModule, + AdminModule, // top level component UiComponent, // Global Angular Material modules diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index e560188ba9..44c1165064 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -22,16 +22,7 @@ import { UserAccountComponent } from "./core/user/user-account/user-account.comp import { SupportComponent } from "./core/support/support/support.component"; import { AuthGuard } from "./core/session/auth.guard"; import { LoginComponent } from "./core/session/login/login.component"; - -/** - * Marks a class to be the target when routing. - * Use this by adding the annotation `@RouteTarget("...")` to a component. - * The name provided to the annotation can then be used in the configuration. - * - * IMPORTANT: - * The component also needs to be added to the `...Components` list of the respective module. - */ -export const RouteTarget = (_name: string) => (_) => undefined; +import { AdminModule } from "./core/admin/admin.module"; /** * All routes configured for the main app routing. @@ -60,6 +51,11 @@ export const allRoutes: Routes = [ (c) => c.PublicFormComponent, ), }, + { + path: "admin", + // add directly without lazy-loading so that Menu can detect permissions for child routes + children: AdminModule.routes, + }, { path: "login", component: LoginComponent }, { path: "404", component: NotFoundComponent }, 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 f465cc1a85..6837680333 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 @@ -4,13 +4,13 @@ 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 { RouteTarget } from "../../../app.routing"; import { NgIf } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatTooltipModule } from "@angular/material/tooltip"; import { RollCallSetupComponent } from "./roll-call-setup/roll-call-setup.component"; import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("AddDayAttendance") @Component({ diff --git a/src/app/child-dev-project/attendance/attendance-manager/attendance-manager.component.ts b/src/app/child-dev-project/attendance/attendance-manager/attendance-manager.component.ts index 6999d63652..714ad4caba 100644 --- a/src/app/child-dev-project/attendance/attendance-manager/attendance-manager.component.ts +++ b/src/app/child-dev-project/attendance/attendance-manager/attendance-manager.component.ts @@ -1,10 +1,10 @@ import { Component } from "@angular/core"; import { ComingSoonDialogService } from "../../../features/coming-soon/coming-soon-dialog.service"; -import { RouteTarget } from "../../../app.routing"; import { MatCardModule } from "@angular/material/card"; import { MatButtonModule } from "@angular/material/button"; import { RouterLink } from "@angular/router"; import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("AttendanceManager") @Component({ diff --git a/src/app/child-dev-project/attendance/attendance.module.ts b/src/app/child-dev-project/attendance/attendance.module.ts index c2ee3e428e..b5ef08dc8e 100644 --- a/src/app/child-dev-project/attendance/attendance.module.ts +++ b/src/app/child-dev-project/attendance/attendance.module.ts @@ -20,8 +20,18 @@ import { ComponentRegistry } from "../../dynamic-components"; import { attendanceComponents } from "./attendance-components"; import { RecurringActivity } from "./model/recurring-activity"; import { EventNote } from "./model/event-note"; +import { DefaultDatatype } from "../../core/entity/default-datatype/default.datatype"; +import { EventAttendanceDatatype } from "./model/event-attendance.datatype"; -@NgModule({}) +@NgModule({ + providers: [ + { + provide: DefaultDatatype, + useClass: EventAttendanceDatatype, + multi: true, + }, + ], +}) export class AttendanceModule { static databaseEntities = [RecurringActivity, EventNote]; diff --git a/src/app/child-dev-project/attendance/model/event-attendance.datatype.spec.ts b/src/app/child-dev-project/attendance/model/event-attendance.datatype.spec.ts new file mode 100644 index 0000000000..8bc0edf10a --- /dev/null +++ b/src/app/child-dev-project/attendance/model/event-attendance.datatype.spec.ts @@ -0,0 +1,32 @@ +import { testDatatype } from "../../../core/entity/schema/entity-schema.service.spec"; +import { EventAttendanceDatatype } from "./event-attendance.datatype"; +import { EventAttendance } from "./event-attendance"; +import { defaultAttendanceStatusTypes } from "../../../core/config/default-config/default-attendance-status-types"; +import { DefaultDatatype } from "../../../core/entity/default-datatype/default.datatype"; +import { StringDatatype } from "../../../core/basic-datatypes/string/string.datatype"; +import { ConfigurableEnumDatatype } from "../../../core/basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype"; +import { ConfigurableEnumService } from "../../../core/basic-datatypes/configurable-enum/configurable-enum.service"; + +describe("Schema data type: event-attendance", () => { + testDatatype( + EventAttendanceDatatype, + new EventAttendance(defaultAttendanceStatusTypes[0], "test remark"), + { + status: defaultAttendanceStatusTypes[0].id, + remarks: "test remark", + }, + undefined, + [ + { provide: DefaultDatatype, useClass: StringDatatype, multi: true }, + { + provide: DefaultDatatype, + useClass: ConfigurableEnumDatatype, + multi: true, + }, + { + provide: ConfigurableEnumService, + useValue: { getEnumValues: () => defaultAttendanceStatusTypes }, + }, + ], + ); +}); diff --git a/src/app/child-dev-project/attendance/model/event-attendance.datatype.ts b/src/app/child-dev-project/attendance/model/event-attendance.datatype.ts new file mode 100644 index 0000000000..bdec6e97be --- /dev/null +++ b/src/app/child-dev-project/attendance/model/event-attendance.datatype.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@angular/core"; +import { SchemaEmbedDatatype } from "../../../core/basic-datatypes/schema-embed/schema-embed.datatype"; +import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; +import { EntityConstructor } from "../../../core/entity/model/entity"; +import { EventAttendance } from "./event-attendance"; + +@Injectable() +export class EventAttendanceDatatype extends SchemaEmbedDatatype { + static override dataType = EventAttendance.DATA_TYPE; + + override embeddedType = EventAttendance as unknown as EntityConstructor; + + constructor(schemaService: EntitySchemaService) { + super(schemaService); + } +} 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 022e16fb9c..e8b3ed0e0f 100644 --- a/src/app/child-dev-project/attendance/model/event-attendance.ts +++ b/src/app/child-dev-project/attendance/model/event-attendance.ts @@ -10,6 +10,8 @@ import { DatabaseField } from "../../../core/entity/database-field.decorator"; * TODO overwork this concept to either be a sublass of Entity or not (at the moment it uses a lot of casting, e.g. to be used in the entity subrecord) */ export class EventAttendance { + static DATA_TYPE = "event-attendance"; + private _status: AttendanceStatusType; @DatabaseField({ dataType: "configurable-enum", 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 03fd7dc774..918d73e1da 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 @@ -4,8 +4,8 @@ import { ActivatedRoute } from "@angular/router"; import { ChildrenService } from "../children.service"; import { EntityListConfig } from "../../../core/entity-list/EntityListConfig"; import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; -import { RouteTarget } from "../../../app.routing"; import { EntityListComponent } from "../../../core/entity-list/entity-list/entity-list.component"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("ChildrenList") @Component({ diff --git a/src/app/child-dev-project/notes/model/note.ts b/src/app/child-dev-project/notes/model/note.ts index 20ded07d3d..0d56f048d5 100644 --- a/src/app/child-dev-project/notes/model/note.ts +++ b/src/app/child-dev-project/notes/model/note.ts @@ -89,8 +89,7 @@ export class Note extends Entity { * No direct access to change this property. Use the `.getAttendance()` method to have safe access. */ @DatabaseField({ - innerDataType: "schema-embed", - additional: EventAttendance, + innerDataType: EventAttendance.DATA_TYPE, anonymize: "retain", }) private childrenAttendance: Map = new Map(); 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 a7ae7e67b7..3e8301e19c 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 @@ -14,7 +14,6 @@ import { EventNote } from "../../attendance/model/event-note"; import { WarningLevel } from "../../warning-level"; import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; import { merge } from "rxjs"; -import { RouteTarget } from "../../../app.routing"; import moment from "moment"; import { MatSlideToggleModule } from "@angular/material/slide-toggle"; import { NgIf } from "@angular/common"; @@ -22,6 +21,7 @@ import { FormsModule } from "@angular/forms"; import { Angulartics2Module } from "angulartics2"; import { MatMenuModule } from "@angular/material/menu"; import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component"; +import { RouteTarget } from "../../../route-target"; /** * additional config specifically for NotesManagerComponent diff --git a/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.html b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.html new file mode 100644 index 0000000000..1e9136d22e --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.html @@ -0,0 +1,188 @@ +

Configure Field "{{ entitySchemaField.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) + + + + + + {{ fieldIdForm.getError("uniqueId") }} + + + + + Type + + + + + + + Type Details (dropdown options set) + + + + + + + + + + + + Type Details (target record type) + + + + + +
+
+
+ + + +
+
+ + Default Value + + + + + Anonymize + + + + + Searchable + + +
+ +
+ + Field Validation + + +
+
+
+
+
+
+ + + + + diff --git a/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.scss b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.scss new file mode 100644 index 0000000000..7c9816d921 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.scss @@ -0,0 +1,8 @@ +@use "../../../../../styles/mixins/grid-layout"; + +.grid-layout { + @include grid-layout.adaptive( + $min-block-width: 250px, + $max-screen-width: 414px + ); +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.spec.ts b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.spec.ts new file mode 100644 index 0000000000..fbe703df7e --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.spec.ts @@ -0,0 +1,211 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; +import { AdminEntityFieldComponent } from "./admin-entity-field.component"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { CoreTestingModule } from "../../../../utils/core-testing.module"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { HarnessLoader } from "@angular/cdk/testing"; +import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; +import { MatFormFieldHarness } from "@angular/material/form-field/testing"; +import { MatInputHarness } from "@angular/material/input/testing"; +import { Entity } from "../../../entity/model/entity"; +import { ConfigurableEnumDatatype } from "../../../basic-datatypes/configurable-enum/configurable-enum-datatype/configurable-enum.datatype"; +import { EntityDatatype } from "../../../basic-datatypes/entity/entity.datatype"; +import { StringDatatype } from "../../../basic-datatypes/string/string.datatype"; +import { ConfigurableEnumService } from "../../../basic-datatypes/configurable-enum/configurable-enum.service"; +import { generateIdFromLabel } from "../../../../utils/generate-id-from-label/generate-id-from-label"; +import { + DatabaseEntity, + EntityRegistry, +} from "../../../entity/database-entity.decorator"; +import { Validators } from "@angular/forms"; +import { RecurringActivity } from "../../../../child-dev-project/attendance/model/recurring-activity"; +import { AdminEntityService } from "../../admin-entity.service"; +import { EntitySchemaField } from "../../../entity/schema/entity-schema-field"; + +describe("AdminEntityFieldComponent", () => { + let component: AdminEntityFieldComponent; + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AdminEntityFieldComponent, + CoreTestingModule, + FontAwesomeTestingModule, + NoopAnimationsModule, + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + entitySchemaField: {}, + entityType: Entity, + }, + }, + { provide: MatDialogRef, useValue: { close: () => null } }, + ], + }); + fixture = TestBed.createComponent(AdminEntityFieldComponent); + loader = TestbedHarnessEnvironment.loader(fixture); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should generate id (if new field) from label", async () => { + const labelInput = await loader + .getHarness(MatFormFieldHarness.with({ floatingLabelText: "Label" })) + .then((field) => field.getControl(MatInputHarness)); + const idInput = await loader + .getHarness( + MatFormFieldHarness.with({ floatingLabelText: "Field ID (readonly)" }), + ) + .then((field) => field.getControl(MatInputHarness)); + + // Initially ID is automatically generated from label + await labelInput.setValue("new label"); + await expectAsync(idInput.getValue()).toBeResolvedTo("newLabel"); + + // manual edit of ID field stops auto generation of ID + await idInput.setValue("my_id"); + await labelInput.setValue("other label"); + await expectAsync(idInput.getValue()).toBeResolvedTo("my_id"); + }); + + it("should include 'additional' field only for relevant datatypes", fakeAsync(() => { + const dataTypeForm = component.schemaFieldsForm.get("dataType"); + let additionalInput: MatInputHarness; + function findAdditionalInputComponent() { + loader + .getHarness( + MatFormFieldHarness.with({ + floatingLabelText: /Type Details.*/, + }), + ) + .then((field) => field.getControl(MatInputHarness)) + .then((input) => (additionalInput = input)) + .catch(() => (additionalInput = undefined)); + tick(); + } + + dataTypeForm.setValue(ConfigurableEnumDatatype.dataType); + tick(); + findAdditionalInputComponent(); + expect(additionalInput).toBeTruthy(); + expect( + component.additionalForm.hasValidator(Validators.required), + ).toBeTrue(); + + dataTypeForm.setValue(StringDatatype.dataType); + tick(); + findAdditionalInputComponent(); + expect(additionalInput).toBeUndefined(); + expect(component.additionalForm.value).toBeNull(); + expect( + component.additionalForm.hasValidator(Validators.required), + ).toBeFalse(); + + dataTypeForm.setValue(EntityDatatype.dataType); + tick(); + findAdditionalInputComponent(); + expect(additionalInput).toBeTruthy(); + expect( + component.additionalForm.hasValidator(Validators.required), + ).toBeTrue(); + })); + + it("should init 'additional' options for configurable-enum", fakeAsync(() => { + const mockEnumList = ["A", "B"]; + const enumService = TestBed.inject(ConfigurableEnumService); + spyOn(enumService, "listEnums").and.returnValue(mockEnumList); + + const dataTypeForm = component.schemaFieldsForm.get("dataType"); + dataTypeForm.setValue(ConfigurableEnumDatatype.dataType); + tick(); + + expect(component.typeAdditionalOptions).toEqual( + mockEnumList.map((x) => ({ value: x, label: x })), + ); + })); + + it("should init 'additional' value from schema field for configurable-enum", fakeAsync(() => { + component.entitySchemaField.additional = "test-enum"; + component.schemaFieldsForm + .get("label") + .setValue("label ignored for enum id"); + expect(component.additionalForm.value).toBeNull(); + + const dataTypeForm = component.schemaFieldsForm.get("dataType"); + dataTypeForm.setValue(ConfigurableEnumDatatype.dataType); + tick(); + expect(component.additionalForm.value).toBe("test-enum"); + })); + it("should generate 'additional' value from label for configurable-enum", fakeAsync(() => { + component.schemaFieldsForm.get("label").setValue("test label"); + tick(); + + const dataTypeForm = component.schemaFieldsForm.get("dataType"); + dataTypeForm.setValue(ConfigurableEnumDatatype.dataType); + tick(); + expect(component.additionalForm.value).toBe( + generateIdFromLabel("test label"), + ); + })); + + it("should init 'additional' options for entity datatypes", fakeAsync(() => { + const mockEntityTypes = [Entity, RecurringActivity]; + const entityRegistry = TestBed.inject(EntityRegistry); + spyOn(entityRegistry, "getEntityTypes").and.returnValue( + mockEntityTypes.map((x) => ({ key: x.ENTITY_TYPE, value: x })), + ); + + component.entitySchemaField.additional = RecurringActivity.ENTITY_TYPE; + + const dataTypeForm = component.schemaFieldsForm.get("dataType"); + dataTypeForm.setValue(EntityDatatype.dataType); + tick(); + + expect(component.typeAdditionalOptions).toEqual([ + { value: "Entity", label: "Entity" }, + { value: RecurringActivity.ENTITY_TYPE, label: RecurringActivity.label }, + ]); + expect(component.additionalForm.value).toBe(RecurringActivity.ENTITY_TYPE); + })); + + it("should update entityConstructor schema upon save", fakeAsync(() => { + const testFieldData: EntitySchemaField = { + label: "test field", + dataType: "string", + _isCustomizedField: true, + }; + + @DatabaseEntity("EntityUpdatedInAdminUI") + class EntityUpdatedInAdminUI extends Entity {} + + component.entityType = EntityUpdatedInAdminUI; + component.fieldId = "testField"; + component.schemaFieldsForm.get("label").setValue(testFieldData.label); + component.schemaFieldsForm.get("dataType").setValue(testFieldData.dataType); + + const adminService = TestBed.inject(AdminEntityService); + spyOn(adminService.entitySchemaUpdated, "next"); + + component.save(); + tick(); + + expect(EntityUpdatedInAdminUI.schema.get("testField")).toEqual( + testFieldData, + ); + expect(adminService.entitySchemaUpdated.next).toHaveBeenCalled(); + })); +}); diff --git a/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.ts b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.ts new file mode 100644 index 0000000000..0ccea0ad79 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.component.ts @@ -0,0 +1,309 @@ +import { + Component, + Inject, + Input, + OnChanges, + SimpleChanges, +} from "@angular/core"; +import { Entity, EntityConstructor } from "../../../entity/model/entity"; +import { + MAT_DIALOG_DATA, + MatDialog, + 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"; +import { ErrorHintComponent } from "../../../common-components/error-hint/error-hint.component"; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} 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 { EntityRegistry } from "../../../entity/database-entity.decorator"; +import { uniqueIdValidator } from "../../../common-components/entity-form/unique-id-validator"; +import { AdminEntityService } from "../../admin-entity.service"; +import { ConfigureEnumPopupComponent } from "../../../basic-datatypes/configurable-enum/configure-enum-popup/configure-enum-popup.component"; +import { ConfigurableEnum } from "../../../basic-datatypes/configurable-enum/configurable-enum"; +import { generateIdFromLabel } from "../../../../utils/generate-id-from-label/generate-id-from-label"; +import { merge } from "rxjs"; +import { filter } from "rxjs/operators"; + +/** + * Allows configuration of the schema of a single Entity field, like its dataType and labels. + */ +@Component({ + selector: "app-admin-entity-field", + templateUrl: "./admin-entity-field.component.html", + styleUrls: [ + "./admin-entity-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 AdminEntityFieldComponent implements OnChanges { + @Input() fieldId: string; + @Input() entityType: EntityConstructor; + + entitySchemaField: EntitySchemaField; + + form: FormGroup; + fieldIdForm: FormControl; + /** form group of all fields in EntitySchemaField (i.e. without fieldId) */ + schemaFieldsForm: FormGroup; + additionalForm: FormControl; + typeAdditionalOptions: SimpleDropdownValue[]; + dataTypes: SimpleDropdownValue[] = []; + + constructor( + @Inject(MAT_DIALOG_DATA) + data: { + fieldId: string; + entityType: EntityConstructor; + }, + private dialogRef: MatDialogRef, + private fb: FormBuilder, + @Inject(DefaultDatatype) allDataTypes: DefaultDatatype[], + private configurableEnumService: ConfigurableEnumService, + private entityRegistry: EntityRegistry, + private adminEntityService: AdminEntityService, + private dialog: MatDialog, + ) { + this.fieldId = data.fieldId; + this.entityType = data.entityType; + this.entitySchemaField = this.entityType.schema.get(this.fieldId) ?? {}; + + this.initSettings(); + this.initAvailableDatatypes(allDataTypes); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.entitySchemaField) { + this.initSettings(); + } + } + + private initSettings() { + this.fieldIdForm = this.fb.control(this.fieldId, [ + Validators.required, + uniqueIdValidator(Array.from(this.entityType.schema.keys())), + ]); + this.additionalForm = this.fb.control(this.entitySchemaField.additional); + + this.schemaFieldsForm = this.fb.group({ + label: [this.entitySchemaField.label, Validators.required], + labelShort: [this.entitySchemaField.labelShort], + description: [this.entitySchemaField.description], + + dataType: [this.entitySchemaField.dataType, Validators.required], + additional: this.additionalForm, + + // TODO: remove "innerDataType" completely - the UI can only support very specific multi-valued types anyway + innerDataType: [this.entitySchemaField.innerDataType], + + defaultValue: [this.entitySchemaField.defaultValue], + searchable: [this.entitySchemaField.searchable], + anonymize: [this.entitySchemaField.anonymize], + //viewComponent: [], + //editComponent: [], + //showInDetailsView: [], + //generateIndex: [], + validators: [this.entitySchemaField.validators], + }); + this.form = this.fb.group({ + id: this.fieldIdForm, + schemaFields: this.schemaFieldsForm, + }); + + this.schemaFieldsForm + .get("labelShort") + .valueChanges.pipe(filter((v) => v === "")) + .subscribe((v) => { + // labelShort should never be empty string, in that case it has to be removed so that label works as fallback + this.schemaFieldsForm.get("labelShort").setValue(null); + }); + this.updateDataTypeAdditional(this.schemaFieldsForm.get("dataType").value); + this.schemaFieldsForm + .get("dataType") + .valueChanges.subscribe((v) => this.updateDataTypeAdditional(v)); + this.updateForNewOrExistingField(); + } + + private updateForNewOrExistingField() { + if (!!this.fieldId) { + // existing fields' id is readonly + this.fieldIdForm.disable(); + } else { + const autoGenerateSubscr = merge( + this.schemaFieldsForm.get("label").valueChanges, + this.schemaFieldsForm.get("labelShort").valueChanges, + ).subscribe(() => this.autoGenerateId()); + // stop updating id when user manually edits + this.fieldIdForm.valueChanges.subscribe(() => + autoGenerateSubscr.unsubscribe(), + ); + } + } + private autoGenerateId() { + // prefer labelShort if it exists, as this makes less verbose IDs + const label = + this.schemaFieldsForm.get("labelShort").value ?? + this.schemaFieldsForm.get("label").value; + const generatedId = generateIdFromLabel(label); + this.fieldIdForm.setValue(generatedId, { emitEvent: false }); + } + + private initAvailableDatatypes(dataTypes: DefaultDatatype[]) { + this.dataTypes = dataTypes + .filter((d) => d.label !== DefaultDatatype.label) // hide "internal" technical dataTypes that did not define a human-readable label + .map((d) => ({ + label: d.label, + value: d.dataType, + })); + } + objectToLabel = (v: SimpleDropdownValue) => v?.label; + objectToValue = (v: SimpleDropdownValue) => v?.value; + createNewAdditionalOption: (input: string) => SimpleDropdownValue; + + private updateDataTypeAdditional(dataType: string) { + this.resetAdditional(); + + if (dataType === ConfigurableEnumDatatype.dataType) { + this.initAdditionalForEnum(); + } else if ( + dataType === EntityDatatype.dataType || + dataType === EntityArrayDatatype.dataType + ) { + this.initAdditionalForEntityRef(); + } + + // hasInnerType: [ArrayDatatype.dataType].includes(d.dataType), + + // TODO: this mapping of having an "additional" schema should probably become part of Datatype classes + } + + private initAdditionalForEnum() { + this.typeAdditionalOptions = this.configurableEnumService + .listEnums() + .map((x) => ({ + label: Entity.extractEntityIdFromId(x), // TODO: add human-readable label to configurable-enum entities + value: Entity.extractEntityIdFromId(x), + })); + this.additionalForm.addValidators(Validators.required); + + this.createNewAdditionalOption = (text) => ({ + value: generateIdFromLabel(text), + label: text, + }); + + if (this.entitySchemaField.additional) { + this.additionalForm.setValue(this.entitySchemaField.additional); + } else if (this.schemaFieldsForm.get("label").value) { + // when switching to enum datatype in the form, if unset generate a suggested enum-id immediately + const newOption = this.createNewAdditionalOption( + this.schemaFieldsForm.get("label").value, + ); + this.typeAdditionalOptions.push(newOption); + this.additionalForm.setValue(newOption.value); + } + } + + private initAdditionalForEntityRef() { + this.typeAdditionalOptions = this.entityRegistry + .getEntityTypes(true) + .map((x) => ({ label: x.value.label, value: x.value.ENTITY_TYPE })); + + this.additionalForm.addValidators(Validators.required); + if ( + this.typeAdditionalOptions.some( + (x) => x.value === this.entitySchemaField.additional, + ) + ) { + this.additionalForm.setValue(this.entitySchemaField.additional); + } + } + + private resetAdditional() { + this.additionalForm.removeValidators(Validators.required); + this.additionalForm.reset(null); + this.typeAdditionalOptions = undefined; + this.createNewAdditionalOption = undefined; + } + + save() { + this.form.markAllAsTouched(); + if (this.form.invalid) { + return; + } + + const formValues = this.schemaFieldsForm.getRawValue(); + for (const key of Object.keys(formValues)) { + if (formValues[key] === null) { + delete formValues[key]; + } + } + const updatedEntitySchema = Object.assign( + { _isCustomizedField: true }, + this.entitySchemaField, // TODO: remove this merge once all schema fields are in the form (then only form values should apply) + formValues, + ); + const fieldId = this.fieldIdForm.getRawValue(); + + this.adminEntityService.updateSchemaField( + this.entityType, + fieldId, + updatedEntitySchema, + ); + + this.dialogRef.close(fieldId); + } + + openEnumOptions(event: Event) { + event.stopPropagation(); // do not open the autocomplete dropdown when clicking the settings icon + + let enumEntity = this.configurableEnumService.getEnum( + this.additionalForm.value, + ); + if (!enumEntity) { + // if the user makes changes, the dialog component itself is saving the new entity to the database already + enumEntity = new ConfigurableEnum(this.additionalForm.value); + } + this.dialog.open(ConfigureEnumPopupComponent, { data: enumEntity }); + } +} + +interface SimpleDropdownValue { + label: string; + value: string; +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.stories.ts b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.stories.ts new file mode 100644 index 0000000000..f686e8830b --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-field/admin-entity-field.stories.ts @@ -0,0 +1,56 @@ +import { + applicationConfig, + Meta, + moduleMetadata, + StoryFn, +} from "@storybook/angular"; +import { StorybookBaseModule } from "../../../../utils/storybook-base.module"; +import { importProvidersFrom } from "@angular/core"; +import { AdminEntityFieldComponent } from "./admin-entity-field.component"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { EntitySchemaField } from "../../../entity/schema/entity-schema-field"; +import { School } from "../../../../child-dev-project/schools/model/school"; + +export default { + title: "Core/Admin/Entity Field", + component: AdminEntityFieldComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(StorybookBaseModule)], + }), + moduleMetadata({ + imports: [AdminEntityFieldComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + entitySchemaField: { id: null }, + }, + }, + { provide: MatDialogRef, useValue: null }, + ], + }), + ], +} as Meta; + +const Template: StoryFn = (args) => ({ + component: AdminEntityFieldComponent, + props: args, +}); + +export const EditExisting = Template.bind({}); +EditExisting.args = { + fieldId: "name", + entitySchemaField: { + dataType: "string", + label: "Firstname", + description: "abc", + } as EntitySchemaField, + entityType: School, +}; + +export const CreateNew = Template.bind({}); +CreateNew.args = { + fieldId: null, + entityType: School, +}; diff --git a/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.html b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.html new file mode 100644 index 0000000000..3390d5d6ae --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.html @@ -0,0 +1,124 @@ +
+ +
+ +
+ + + +
+ +
+ + + + +
+ +
+
+ +
+
+ + +
+
+ drop here to create new field group + +
+
+
+ + + + +
+ hidden fields
+ drag & drop to / from here + +
+ +
+ + +
+ + + +
+
+
+
+
diff --git a/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.scss b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.scss new file mode 100644 index 0000000000..95c7c99680 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.scss @@ -0,0 +1,125 @@ +@use "../../../../../styles/variables/colors"; +@use "../../../../../styles/variables/sizes"; +@use "../../../../../styles/mixins/grid-layout"; +@use "../../../../../../node_modules/@angular/material/core/style/elevation" as mat-elevation; + +$toolbar-width: 300px; +.toolbar { + width: $toolbar-width; + padding: sizes.$small; + margin-right: - sizes.$small; + margin-bottom: - sizes.$small; + + @include mat-elevation.elevation(4); + border-bottom-left-radius: 0; + border-top-right-radius: 0; + background-color: transparent; +} + +.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 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; +} + +.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; + min-width: 2em; + text-align: center; +} + +.field-edit-button { + visibility: hidden; + background: white !important; + z-index: 10; + padding: 1.5em; + + position: absolute; + // center within parent: + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + width: fit-content; +} +.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 } +.dummy-form-field ::ng-deep mat-form-field { + width: 100% +} + + +.drop-area-hint { + text-align: center; + padding: sizes.$small; + color: colors.$hint-text +} + +.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.4; + border-color: green; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.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/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.spec.ts b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.spec.ts new file mode 100644 index 0000000000..804488d3e1 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.spec.ts @@ -0,0 +1,181 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { AdminEntityFormComponent } from "./admin-entity-form.component"; +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"; +import { Note } from "../../../../child-dev-project/notes/model/note"; +import { FormGroup } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { CdkDragDrop } from "@angular/cdk/drag-drop"; +import { of } from "rxjs"; +import { ColumnConfig } from "../../../common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; +import { AdminModule } from "../../admin.module"; +import { FormConfig } from "../../../entity-details/form/form.component"; + +describe("AdminEntityFormComponent", () => { + let component: AdminEntityFormComponent; + let fixture: ComponentFixture; + + let mockFormService: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + + let testConfig: FormConfig; + + beforeEach(() => { + testConfig = { + fieldGroups: [ + { header: "Group 1", fields: ["subject", "date"] }, + { fields: ["category"] }, + ], + }; + + mockFormService = jasmine.createSpyObj("EntityFormService", [ + "createFormGroup", + ]); + mockFormService.createFormGroup.and.returnValue(new FormGroup({})); + mockDialog = jasmine.createSpyObj("MatDialog", ["open"]); + + TestBed.configureTestingModule({ + imports: [ + AdminModule, + CoreTestingModule, + FontAwesomeTestingModule, + NoopAnimationsModule, + ], + providers: [ + { + provide: EntityFormService, + useValue: mockFormService, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + ], + }); + fixture = TestBed.createComponent(AdminEntityFormComponent); + component = fixture.componentInstance; + + component.config = testConfig; + component.entityType = Note; + + fixture.detectChanges(); + + component.ngOnChanges({ config: true as any }); + }); + + it("should create and init a form", () => { + expect(component).toBeTruthy(); + + expect(component.dummyEntity).toBeTruthy(); + expect(component.dummyForm).toBeTruthy(); + }); + + it("should load all fields from schema that are not already in form as available fields", () => { + const fieldsInView = ["date"]; + component.config = { + fieldGroups: [{ fields: fieldsInView }], + }; + component.ngOnChanges({ config: true as any }); + + const noteUserFacingFields = Array.from(Note.schema.entries()) + .filter(([key, value]) => value.label) + .map(([key]) => key); + expect(component.availableFields).toEqual([ + component.createNewFieldPlaceholder, + ...noteUserFacingFields.filter((x) => !fieldsInView.includes(x)), + ]); + }); + + function mockDropNewFieldEvent( + targetContainer: ColumnConfig[], + previousContainer?: ColumnConfig[], + previousIndex?: number, + ) { + previousContainer = previousContainer ?? component.availableFields; + previousIndex = previousIndex ?? 0; // "new field" placeholder is always first + + return { + container: { data: targetContainer }, + currentIndex: 1, + previousContainer: { data: previousContainer }, + previousIndex: previousIndex, + } as Partial> as CdkDragDrop< + ColumnConfig[], + ColumnConfig[] + >; + } + + it("should add new field in view if field config dialog succeeds", fakeAsync(() => { + const newFieldId = "test-created-field"; + mockDialog.open.and.returnValue({ + afterClosed: () => of(newFieldId), + } as any); + + const targetContainer = component.config.fieldGroups[0].fields; + component.drop(mockDropNewFieldEvent(targetContainer)); + tick(); + + expect(mockDialog.open).toHaveBeenCalled(); + expect(targetContainer).toEqual(["subject", newFieldId, "date"]); + expect(component.availableFields).toContain( + component.createNewFieldPlaceholder, + ); + })); + + it("should not add new field in view if field config dialog is cancelled", fakeAsync(() => { + mockDialog.open.and.returnValue({ afterClosed: () => of("") } as any); + + const targetContainer = component.config.fieldGroups[0].fields; + component.drop(mockDropNewFieldEvent(targetContainer)); + tick(); + + expect(targetContainer).toEqual(["subject", "date"]); + expect(mockDialog.open).toHaveBeenCalled(); + expect(component.availableFields).toContain( + component.createNewFieldPlaceholder, + ); + })); + + it("should not create field (show dialog) if new field is dropped on toolbar (available fields)", fakeAsync(() => { + component.drop(mockDropNewFieldEvent(component.availableFields)); + tick(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + })); + + it("should create a new fieldGroup in config on dropping field in new-group drop area", fakeAsync(() => { + const field = component.config.fieldGroups[0].fields[0]; + const dropEvent = mockDropNewFieldEvent( + null, + component.config.fieldGroups[0].fields, + 0, + ); + component.dropNewGroup(dropEvent); + tick(); + + expect(component.config.fieldGroups[2]).toEqual({ fields: [field] }); + })); + + it("should move all fields from removed group to availableFields toolbar", fakeAsync(() => { + const removedFields = component.config.fieldGroups[0].fields; + expect( + removedFields.some((x) => component.availableFields.includes(x)), + ).not.toBeTrue(); + + component.removeGroup(0); + tick(); + + expect(component.config.fieldGroups).toEqual([{ fields: ["category"] }]); + expect(component.availableFields).toEqual( + jasmine.arrayContaining(removedFields), + ); + })); +}); diff --git a/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.ts b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.ts new file mode 100644 index 0000000000..d8ad3cebf1 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.ts @@ -0,0 +1,203 @@ +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { Entity, EntityConstructor } from "../../../entity/model/entity"; +import { EntityFormService } from "../../../common-components/entity-form/entity-form.service"; +import { FormControl, FormGroup } from "@angular/forms"; +import { MatDialog } from "@angular/material/dialog"; +import { AdminEntityFieldComponent } from "../admin-entity-field/admin-entity-field.component"; +import { + CdkDragDrop, + DragDropModule, + moveItemInArray, + transferArrayItem, +} from "@angular/cdk/drag-drop"; +import { + ColumnConfig, + toFormFieldConfig, +} from "../../../common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; +import { FormFieldConfig } from "../../../common-components/entity-form/entity-form/FormConfig"; +import { AdminEntityService } from "../../admin-entity.service"; +import { lastValueFrom } from "rxjs"; +import { NgForOf, NgIf } from "@angular/common"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { MatButtonModule } from "@angular/material/button"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { MatCardModule } from "@angular/material/card"; +import { EntityFieldLabelComponent } from "../../../common-components/entity-field-label/entity-field-label.component"; +import { EntityFieldEditComponent } from "../../../common-components/entity-field-edit/entity-field-edit.component"; +import { AdminSectionHeaderComponent } from "../admin-section-header/admin-section-header.component"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { FormConfig } from "../../../entity-details/form/form.component"; + +@UntilDestroy() +@Component({ + selector: "app-admin-entity-form", + templateUrl: "./admin-entity-form.component.html", + styleUrls: [ + "./admin-entity-form.component.scss", + "../admin-section-header/admin-section-header.component.scss", + "../../../common-components/entity-form/entity-form/entity-form.component.scss", + ], + standalone: true, + imports: [ + DragDropModule, + NgForOf, + FaIconComponent, + MatButtonModule, + MatTooltipModule, + MatCardModule, + EntityFieldLabelComponent, + EntityFieldEditComponent, + AdminSectionHeaderComponent, + NgIf, + ], +}) +export class AdminEntityFormComponent implements OnChanges { + @Input() entityType: EntityConstructor; + + @Input() config: FormConfig; + + dummyEntity: Entity; + dummyForm: FormGroup; + + availableFields: ColumnConfig[] = []; + readonly createNewFieldPlaceholder: FormFieldConfig = { + id: null, + label: "Create New Field", + }; + + constructor( + private entityFormService: EntityFormService, + private matDialog: MatDialog, + adminEntityService: AdminEntityService, + ) { + adminEntityService.entitySchemaUpdated + .pipe(untilDestroyed(this)) + .subscribe(() => this.initForm()); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.config) { + this.initForm(); + } + } + + private initForm() { + this.initAvailableFields(); + + this.dummyEntity = new this.entityType(); + this.dummyForm = this.entityFormService.createFormGroup( + [...this.getUsedFields(this.config), ...this.availableFields], + this.dummyEntity, + ); + this.dummyForm.disable(); + } + + private getUsedFields(config: FormConfig): ColumnConfig[] { + return config.fieldGroups.reduce((p, c) => p.concat(c.fields), []); + } + + /** + * Load any fields from schema that are not already in the form, so that the user can drag them into the form. + * @param config + * @private + */ + private initAvailableFields() { + const usedFields = this.getUsedFields(this.config); + const unusedFields = Array.from(this.entityType.schema.entries()) + .filter( + ([key]) => + !usedFields.some( + (x) => x === key || (x as FormFieldConfig).id === key, + ), + ) + .filter(([key, value]) => value.label) // no technical, internal fields + .map(([key]) => key); + + this.availableFields = [this.createNewFieldPlaceholder, ...unusedFields]; + } + + /** + * Open the form to edit details of a single field's schema. + * + * @param field field to edit or { id: null } to create a new field + * @returns the id of the field that was edited or created (which is newly defined in the dialog for new fields) + */ + async openFieldConfig(field: ColumnConfig): Promise { + let fieldIdToEdit = toFormFieldConfig(field).id; + const dialogRef = this.matDialog.open(AdminEntityFieldComponent, { + width: "99%", + maxHeight: "90vh", + data: { + fieldId: fieldIdToEdit, + entityType: this.entityType, + }, + }); + return lastValueFrom(dialogRef.afterClosed()); + } + + drop(event: CdkDragDrop) { + const prevFieldsArray = event.previousContainer.data; + const newFieldsArray = event.container.data; + + if ( + prevFieldsArray[event.previousIndex] === this.createNewFieldPlaceholder + ) { + this.dropNewField(event); + return; + } + + if (event.previousContainer === event.container) { + moveItemInArray(newFieldsArray, event.previousIndex, event.currentIndex); + } else { + transferArrayItem( + prevFieldsArray, + newFieldsArray, + event.previousIndex, + event.currentIndex, + ); + } + + if (newFieldsArray === this.availableFields && event.currentIndex === 0) { + // ensure "create new field" is always first + moveItemInArray(newFieldsArray, event.currentIndex, 1); + } + } + + /** + * drop handler specifically for the "create new field" item + * @param event + * @private + */ + private async dropNewField( + event: CdkDragDrop, + ) { + if (event.container.data === this.availableFields) { + // don't add new field to the available fields that are not in the form yet + return; + } + + const newFieldId = await this.openFieldConfig({ id: null }); + if (!newFieldId) { + return; + } + + this.dummyForm.addControl(newFieldId, new FormControl()); + this.dummyForm.disable(); + event.container.data.splice(event.currentIndex, 0, newFieldId); + + // the schema update has added the new field to the available fields already, remove it from there + this.availableFields.splice(this.availableFields.indexOf(newFieldId), 1); + } + + dropNewGroup(event: CdkDragDrop) { + const newCol = { fields: [] }; + this.config.fieldGroups.push(newCol); + event.container.data = newCol.fields; + this.drop(event); + } + + removeGroup(i: number) { + const [removedFieldGroup] = this.config.fieldGroups.splice(i, 1); + this.availableFields.push(...removedFieldGroup.fields); + } +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.html b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.html new file mode 100644 index 0000000000..eedc331102 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.html @@ -0,0 +1,7 @@ +
+

[ {{ config.component }} ]

+ +

+ Editing advanced sub-sections is not supported yet in this preview. +

+
diff --git a/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.scss b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.spec.ts b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.spec.ts new file mode 100644 index 0000000000..6d41679f1e --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { AdminEntityPanelComponentComponent } from "./admin-entity-panel-component.component"; + +describe("AdminEntityPanelComponentComponent", () => { + let component: AdminEntityPanelComponentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminEntityPanelComponentComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminEntityPanelComponentComponent); + component = fixture.componentInstance; + + component.config = { + component: "SomeComponent", + }; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.ts b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.ts new file mode 100644 index 0000000000..5602ecbfa5 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity-panel-component/admin-entity-panel-component.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PanelComponent } from "../../../entity-details/EntityDetailsConfig"; +import { EntityConstructor } from "../../../entity/model/entity"; + +@Component({ + selector: "app-admin-entity-panel-component", + standalone: true, + imports: [CommonModule], + templateUrl: "./admin-entity-panel-component.component.html", + styleUrl: "./admin-entity-panel-component.component.scss", +}) +export class AdminEntityPanelComponentComponent { + @Input() config: PanelComponent; + @Input() entityType: EntityConstructor; +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.html b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.html new file mode 100644 index 0000000000..0868fae3f2 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.html @@ -0,0 +1,88 @@ +
+ + Editing details page for "{{ entityType | entityTypeLabel }}" records + + +
+ + +
+
+ + + + + + {{ panelConfig.title }} + + + + + + + +
+
+ + + + + + + + +
+ + +
+
+
+ + + + + + + +
diff --git a/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.scss b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.scss new file mode 100644 index 0000000000..e6cd7e82a4 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.scss @@ -0,0 +1,36 @@ +@use "../../../../../../node_modules/@angular/material/core/style/elevation" as mat-elevation; +@use "../../../../../styles/variables/sizes"; + +.admin-ui-title { + font-style: italic; +} + +.save-buttons { + margin: auto 0; +} + +.section-wrapper { + @include mat-elevation.elevation(1); + margin: sizes.$small; + padding: sizes.$small; +} +// TODO: making direct child of app-admin-section-header somehow breaks this +// although `:has(> app-admin-section-header .group-remove-button` (without :hover) does work ... +.section-wrapper:has(> app-admin-section-header .group-remove-button:hover) { + border-color: rgb(255, 0, 0); + background-color: rgba(255, 0, 0, 0.1); +} + + +.section-add-button { + padding: sizes.$large; + margin: sizes.$small; +} + +:host ::ng-deep .mat-mdc-tab { + // adjust tab header height to properly display mat-form-field for editing tab label + height: 60px !important; +} +app-admin-section-header { + margin-bottom: -1em; +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.spec.ts b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.spec.ts new file mode 100644 index 0000000000..e9ba2b06f0 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.spec.ts @@ -0,0 +1,171 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { AdminEntityDetailsComponent } from "./admin-entity-details.component"; +import { ConfigService } from "../../../config/config.service"; +import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service"; +import { + mockEntityMapper, + MockEntityMapperService, +} from "../../../entity/entity-mapper/mock-entity-mapper-service"; +import { Config } from "../../../config/config"; +import { EntityActionsService } from "../../../entity/entity-actions/entity-actions.service"; +import { CoreModule } from "../../../core.module"; +import { CoreTestingModule } from "../../../../utils/core-testing.module"; +import { EntityTypeLabelPipe } from "../../../common-components/entity-type-label/entity-type-label.pipe"; +import { + EntityDetailsConfig, + Panel, +} from "../../../entity-details/EntityDetailsConfig"; +import { Entity } from "../../../entity/model/entity"; +import { MatTabsModule } from "@angular/material/tabs"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { EntitySchemaField } from "../../../entity/schema/entity-schema-field"; +import { DatabaseEntity } from "../../../entity/database-entity.decorator"; +import { DatabaseField } from "../../../entity/database-field.decorator"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; + +describe("AdminEntityDetailsComponent", () => { + let component: AdminEntityDetailsComponent; + let fixture: ComponentFixture; + + let mockConfigService: jasmine.SpyObj; + let entityMapper: MockEntityMapperService; + + let config; + let viewConfig: EntityDetailsConfig; + let viewConfigId, entityConfigId; + + @DatabaseEntity("AdminTest") + class AdminTestEntity extends Entity { + static readonly ENTITY_TYPE = "AdminTest"; + + @DatabaseField({ label: "Name" }) name: string; + } + + beforeEach(() => { + viewConfigId = `view:${AdminTestEntity.route.substring(1)}/:id`; + entityConfigId = `entity:${AdminTestEntity.ENTITY_TYPE}`; + viewConfig = { + entity: AdminTestEntity.ENTITY_TYPE, + panels: [{ title: "Tab 1", components: [] }], + }; + config = { + [viewConfigId]: { component: "EntityDetails", config: viewConfig }, + [entityConfigId]: {}, + }; + + mockConfigService = jasmine.createSpyObj(["getConfig"]); + mockConfigService.getConfig.and.returnValue(config[viewConfigId]); + + entityMapper = mockEntityMapper([new Config(Config.CONFIG_KEY, config)]); + + TestBed.configureTestingModule({ + imports: [ + AdminEntityDetailsComponent, + CoreTestingModule, + CoreModule, + EntityTypeLabelPipe, + MatTabsModule, + NoopAnimationsModule, + FontAwesomeTestingModule, + ], + providers: [ + { + provide: EntityMapperService, + useValue: entityMapper, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: EntityActionsService, + useValue: jasmine.createSpyObj(["showSnackbarConfirmationWithUndo"]), + }, + ], + }); + fixture = TestBed.createComponent(AdminEntityDetailsComponent); + component = fixture.componentInstance; + + component.entityType = AdminTestEntity.ENTITY_TYPE; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should add new panel (tab) to config", () => { + component.createPanel(); + + expect(component.configDetailsView.panels.length).toBe( + viewConfig.panels.length + 1, + ); + }); + + it("should add new section (component in panel) to config", () => { + component.addComponent(component.configDetailsView.panels[0]); + + expect(component.configDetailsView.panels[0].components.length).toBe(1); + }); + + it("should reset all entity schema changes on cancel", () => { + // simulate schema changes done through the field config popup form + AdminTestEntity.schema.set("testField", { label: "New field" }); + const existingField = AdminTestEntity.schema.get("name"); + const originalLabelOfExisting = existingField.label; + existingField.label = "Changed existing field"; + + component.cancel(); + + expect(AdminTestEntity.schema.has("testField")).toBeFalse(); + expect(AdminTestEntity.schema.get("name").label).toBe( + originalLabelOfExisting, + ); + }); + + it("should save schema and view config", fakeAsync(() => { + const newSchemaField: EntitySchemaField = { + _isCustomizedField: true, + label: "New field", + }; + AdminTestEntity.schema.set("testField", newSchemaField); + + const newPanel: Panel = { + title: "New Panel", + components: [], + }; + component.configDetailsView.panels.push(newPanel); + + component.save(); + tick(); + + const expectedViewConfig = { + entity: AdminTestEntity.ENTITY_TYPE, + panels: [{ title: "Tab 1", components: [] }, newPanel], + }; + const expectedEntityConfig = { + attributes: jasmine.objectContaining({ + testField: newSchemaField, + }), + }; + + const actual: Config = entityMapper.get( + Config.ENTITY_TYPE, + Config.CONFIG_KEY, + ) as Config; + expect(actual.data[viewConfigId]).toEqual({ + component: "EntityDetails", + config: expectedViewConfig, + }); + expect(actual.data[entityConfigId]).toEqual(expectedEntityConfig); + + AdminTestEntity.schema.delete("testField"); + })); +}); diff --git a/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.ts b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.ts new file mode 100644 index 0000000000..b3e172a552 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.component.ts @@ -0,0 +1,158 @@ +import { Component, Input, OnInit, ViewChild } from "@angular/core"; +import { + EntityDetailsConfig, + Panel, +} from "../../../entity-details/EntityDetailsConfig"; +import { EntityConstructor } from "../../../entity/model/entity"; +import { EntityRegistry } from "../../../entity/database-entity.decorator"; +import { ConfigService } from "../../../config/config.service"; +import { ViewConfig } from "../../../config/dynamic-routing/view-config.interface"; +import { DynamicComponent } from "../../../config/dynamic-components/dynamic-component.decorator"; +import { Location, NgForOf, NgIf } from "@angular/common"; +import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service"; +import { Config } from "../../../config/config"; +import { EntityConfigService } from "../../../entity/entity-config.service"; +import { EntityConfig } from "../../../entity/entity-config"; +import { MatTabGroup, MatTabsModule } from "@angular/material/tabs"; +import { EntityActionsService } from "../../../entity/entity-actions/entity-actions.service"; +import { EntitySchemaField } from "../../../entity/schema/entity-schema-field"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { MatButtonModule } from "@angular/material/button"; +import { EntityTypeLabelPipe } from "../../../common-components/entity-type-label/entity-type-label.pipe"; +import { ViewTitleComponent } from "../../../common-components/view-title/view-title.component"; +import { AdminSectionHeaderComponent } from "../admin-section-header/admin-section-header.component"; +import { AdminEntityFormComponent } from "../admin-entity-form/admin-entity-form.component"; +import { AdminEntityPanelComponentComponent } from "../admin-entity-panel-component/admin-entity-panel-component.component"; +import { MatTooltipModule } from "@angular/material/tooltip"; + +@DynamicComponent("AdminEntityDetails") +@Component({ + selector: "app-admin-entity-details", + templateUrl: "./admin-entity-details.component.html", + styleUrls: ["./admin-entity-details.component.scss"], + standalone: true, + imports: [ + MatTabsModule, + FaIconComponent, + MatButtonModule, + EntityTypeLabelPipe, + ViewTitleComponent, + AdminSectionHeaderComponent, + AdminEntityFormComponent, + AdminEntityPanelComponentComponent, + MatTooltipModule, + NgForOf, + NgIf, + ], +}) +export class AdminEntityDetailsComponent implements OnInit { + @Input() entityType: string; + entityConstructor: EntityConstructor; + private originalEntitySchemaFields: [string, EntitySchemaField][]; + + configDetailsView: EntityDetailsConfig; + + @ViewChild(MatTabGroup) tabGroup: MatTabGroup; + + constructor( + private entities: EntityRegistry, + private configService: ConfigService, + private location: Location, + private entityMapper: EntityMapperService, + private entityActionsService: EntityActionsService, + ) {} + + ngOnInit(): void { + this.init(); + } + + private init() { + this.entityConstructor = this.entities.get(this.entityType); + this.originalEntitySchemaFields = JSON.parse( + JSON.stringify(Array.from(this.entityConstructor.schema.entries())), + ); + + const detailsView: ViewConfig = + this.configService.getConfig( + EntityConfigService.getDetailsViewId(this.entityConstructor), + ); + if (detailsView.component !== "EntityDetails") { + // not supported currently + return; + } + + // work on a deep copy as we are editing in place (for titles, sections, etc.) + this.configDetailsView = JSON.parse(JSON.stringify(detailsView.config)); + } + + cancel() { + this.entityConstructor.schema = new Map(this.originalEntitySchemaFields); + this.location.back(); + } + + async save() { + const originalConfig = await this.entityMapper.load( + Config, + Config.CONFIG_KEY, + ); + const newConfig = originalConfig.copy(); + + this.setViewConfig(newConfig); + this.setEntityConfig(newConfig); + + await this.entityMapper.save(newConfig); + this.entityActionsService.showSnackbarConfirmationWithUndo( + newConfig, + $localize`:Save config confirmation message:updated`, + [originalConfig], + ); + + this.location.back(); + } + + private setViewConfig(newConfig: Config) { + const viewId = EntityConfigService.getDetailsViewId(this.entityConstructor); + newConfig.data[viewId].config = this.configDetailsView; + } + + private setEntityConfig(newConfig: Config) { + const entityConfigKey = + EntityConfigService.PREFIX_ENTITY_CONFIG + this.entityType; + + // init config if not present + newConfig.data[entityConfigKey] = + newConfig.data[entityConfigKey] ?? ({ attributes: {} } as EntityConfig); + newConfig.data[entityConfigKey].attributes = + newConfig.data[entityConfigKey].attributes ?? {}; + + const entitySchemaConfig: EntityConfig = newConfig.data[entityConfigKey]; + + for (const [fieldId, field] of this.entityConstructor.schema.entries()) { + if (!field._isCustomizedField) { + // do not write unchanged default fields from the classes into config + continue; + } + entitySchemaConfig.attributes[fieldId] = field; + } + } + + createPanel() { + const newPanel: Panel = { title: "New Tab", components: [] }; + this.configDetailsView.panels.push(newPanel); + + // wait until view has actually added the new tab before we can auto-select it + setTimeout(() => { + const newTabIndex = this.configDetailsView.panels.length - 1; + this.tabGroup.selectedIndex = newTabIndex; + this.tabGroup.focusTab(newTabIndex); + }); + } + + addComponent(panel: Panel) { + panel.components.push({ + title: "New Section", + component: "Form", // TODO: make this configurable + config: { fieldGroups: [] }, + }); + } +} diff --git a/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.stories.ts b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.stories.ts new file mode 100644 index 0000000000..02943b501b --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-entity/admin-entity-details.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 { AdminEntityDetailsComponent } from "./admin-entity-details.component"; +import { FileModule } from "../../../../features/file/file.module"; +import { AdminModule } from "../../admin.module"; + +export default { + title: "Core/Admin/Entity Details", + component: AdminEntityDetailsComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(StorybookBaseModule)], + }), + moduleMetadata({ + imports: [AdminModule, FileModule], + }), + ], +} as Meta; + +const Template: StoryFn = (args) => ({ + component: AdminEntityDetailsComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = { + entityType: "RecurringActivity", +}; diff --git a/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.html b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.html new file mode 100644 index 0000000000..cdf25cee0e --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.html @@ -0,0 +1,15 @@ +
+ + {{ label }} + + + + +
diff --git a/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.scss b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.scss new file mode 100644 index 0000000000..b7ab047209 --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.scss @@ -0,0 +1,5 @@ + +.section-container:has(.group-remove-button:hover) { + border-color: rgb(255, 0, 0); + background-color: rgba(255, 0, 0, 0.1); +} diff --git a/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.spec.ts b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.spec.ts new file mode 100644 index 0000000000..bb9ed930bc --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.spec.ts @@ -0,0 +1,59 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { AdminSectionHeaderComponent } from "./admin-section-header.component"; +import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +describe("AdminSectionHeaderComponent", () => { + let component: AdminSectionHeaderComponent; + let fixture: ComponentFixture; + + let mockConfirmationDialog: jasmine.SpyObj; + + beforeEach(() => { + mockConfirmationDialog = jasmine.createSpyObj(["getConfirmation"]); + + TestBed.configureTestingModule({ + imports: [ + AdminSectionHeaderComponent, + FontAwesomeTestingModule, + NoopAnimationsModule, + ], + providers: [ + { + provide: ConfirmationDialogService, + useValue: mockConfirmationDialog, + }, + ], + }); + fixture = TestBed.createComponent(AdminSectionHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should only emit removeSection if user confirms confirmation dialog", async () => { + spyOn(component.remove, "emit"); + + mockConfirmationDialog.getConfirmation.and.resolveTo(false); + await component.removeSection(); + expect(mockConfirmationDialog.getConfirmation).toHaveBeenCalled(); + expect(component.remove.emit).not.toHaveBeenCalled(); + + mockConfirmationDialog.getConfirmation.and.resolveTo(true); + await component.removeSection(); + expect(component.remove.emit).toHaveBeenCalled(); + }); + + it("should not show confirmation dialog if disableConfirmation is set", async () => { + spyOn(component.remove, "emit"); + + component.disableConfirmation = true; + await component.removeSection(); + expect(mockConfirmationDialog.getConfirmation).not.toHaveBeenCalled(); + expect(component.remove.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.ts b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.ts new file mode 100644 index 0000000000..bd8c7ba20d --- /dev/null +++ b/src/app/core/admin/admin-entity-details/admin-section-header/admin-section-header.component.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { FormsModule } from "@angular/forms"; +import { MatButtonModule } from "@angular/material/button"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service"; + +/** + * Simple building block for UI Builder for a section title including button to remove the section. + * + * Supports two-way binding for the title. + * + * add css class "section-container" and import this component's scss in the parent's styleUrl + * to get visual highlighting on hovering over the remove button, + * or copy the style from there. + * LIMITATION: multiple hierarchies each using this have to define seperate container classes, otherwise styles will leak + */ +@Component({ + selector: "app-admin-section-header", + standalone: true, + imports: [ + CommonModule, + FaIconComponent, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ], + templateUrl: "./admin-section-header.component.html", + styleUrl: "./admin-section-header.component.scss", +}) +export class AdminSectionHeaderComponent { + @Input() title: string; + + /** supports two-way data binding for the editable title: `(); + + @Output() remove = new EventEmitter(); + + /** disable the confirmation dialog displayed before a remove output is emitted */ + @Input() disableConfirmation = false; + + /** overwrite the label (default: "title") displayed for the form field */ + @Input() + label = $localize`:Admin UI - Config Section Header form field label:Title`; + + constructor(private confirmationDialog: ConfirmationDialogService) {} + + async removeSection() { + if (this.disableConfirmation) { + this.remove.emit(); + return; + } + + const confirmation = await this.confirmationDialog.getConfirmation( + $localize`:Admin UI - Delete Section Confirmation Title:Delete Section?`, + $localize`:Admin UI - Delete Section Confirmation Text:Do you really want to delete this section with all its content?`, + ); + if (confirmation) { + this.remove.emit(); + } + } +} diff --git a/src/app/core/admin/admin-entity.service.spec.ts b/src/app/core/admin/admin-entity.service.spec.ts new file mode 100644 index 0000000000..2014e36ca7 --- /dev/null +++ b/src/app/core/admin/admin-entity.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { AdminEntityService } from "./admin-entity.service"; + +describe("AdminEntityService", () => { + let service: AdminEntityService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AdminEntityService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/admin/admin-entity.service.ts b/src/app/core/admin/admin-entity.service.ts new file mode 100644 index 0000000000..bf0d96e523 --- /dev/null +++ b/src/app/core/admin/admin-entity.service.ts @@ -0,0 +1,27 @@ +import { EventEmitter, Injectable } from "@angular/core"; +import { EntityConstructor } from "../entity/model/entity"; + +/** + * Simply service to centralize updates between various admin components in the form builder. + */ +@Injectable({ + providedIn: "root", +}) +export class AdminEntityService { + public entitySchemaUpdated = new EventEmitter(); + + /** + * Set a new schema field to the given entity and trigger update event for related admin components. + * @param entityType + * @param fieldId + * @param updatedEntitySchema + */ + updateSchemaField( + entityType: EntityConstructor, + fieldId: any, + updatedEntitySchema: any, + ) { + entityType.schema.set(fieldId, updatedEntitySchema); + this.entitySchemaUpdated.next(); + } +} diff --git a/src/app/core/admin/admin.module.ts b/src/app/core/admin/admin.module.ts new file mode 100644 index 0000000000..b35cf2718c --- /dev/null +++ b/src/app/core/admin/admin.module.ts @@ -0,0 +1,35 @@ +import { NgModule } from "@angular/core"; +import { ComponentRegistry } from "../../dynamic-components"; +import { CommonModule } from "@angular/common"; +import { ConflictResolutionModule } from "../../features/conflict-resolution/conflict-resolution.module"; +import { ConfigSetupModule } from "../../features/config-setup/config-setup.module"; +import { adminRoutes } from "./admin.routing"; + +/** + * An intuitive UI for users to set up and configure the application's data structures and views + * directly from within the app itself. + * + * This module provides its own routing and can be lazy-loaded as a whole module. + */ +@NgModule({ + imports: [CommonModule, ConflictResolutionModule, ConfigSetupModule], +}) +export class AdminModule { + static routes = adminRoutes; + + constructor(components: ComponentRegistry) { + components.addAll([ + [ + "Admin", + () => import("./admin/admin.component").then((c) => c.AdminComponent), + ], + [ + "AdminEntity", + () => + import( + "./admin-entity-details/admin-entity/admin-entity-details.component" + ).then((c) => c.AdminEntityDetailsComponent), + ], + ]); + } +} diff --git a/src/app/core/admin/admin.routing.ts b/src/app/core/admin/admin.routing.ts new file mode 100644 index 0000000000..909bfea5c6 --- /dev/null +++ b/src/app/core/admin/admin.routing.ts @@ -0,0 +1,82 @@ +import { Routes } from "@angular/router"; +import { RoutedViewComponent } from "../ui/routed-view/routed-view.component"; +import { AdminComponent } from "./admin/admin.component"; +import { ConfigImportComponent } from "../../features/config-setup/config-import/config-import.component"; +import { ConflictResolutionListComponent } from "../../features/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component"; +import { UserRoleGuard } from "../permissions/permission-guard/user-role.guard"; +import { EntityPermissionGuard } from "../permissions/permission-guard/entity-permission.guard"; +import { AuthGuard } from "../session/auth.guard"; + +export const adminRoutes: Routes = [ + { + path: "", + component: AdminComponent, + canActivate: [UserRoleGuard], + data: { + permittedUserRoles: ["admin_app"], + }, + }, + { + path: "entity/:entityType/details", + component: RoutedViewComponent, + data: { + component: "AdminEntity", + entity: "Config", + requiredPermissionOperation: "update", + }, + canActivate: [AuthGuard, EntityPermissionGuard], + }, + + { + path: "site-settings", + component: RoutedViewComponent, + data: { + component: "EntityDetails", + config: { + entity: "SiteSettings", + id: "global", + panels: [ + { + title: $localize`Site Settings`, + components: [ + { + component: "Form", + config: { + fieldGroups: [ + { fields: ["logo", "favicon"] }, + { + fields: [ + "siteName", + "defaultLanguage", + "displayLanguageSelect", + ], + }, + { fields: ["primary", "secondary", "error", "font"] }, + ], + }, + }, + ], + }, + ], + }, + requiredPermissionOperation: "update", + }, + canActivate: [EntityPermissionGuard], + }, + { + path: "config-import", + component: ConfigImportComponent, + canActivate: [UserRoleGuard], + data: { + permittedUserRoles: ["admin_app"], + }, + }, + { + path: "conflicts", + component: ConflictResolutionListComponent, + canActivate: [UserRoleGuard], + data: { + permittedUserRoles: ["admin_app"], + }, + }, +]; diff --git a/src/app/features/admin/admin/admin.component.html b/src/app/core/admin/admin/admin.component.html similarity index 78% rename from src/app/features/admin/admin/admin.component.html rename to src/app/core/admin/admin/admin.component.html index b1b04b8b9a..a76c8c9547 100644 --- a/src/app/features/admin/admin/admin.component.html +++ b/src/app/core/admin/admin/admin.component.html @@ -1,14 +1,25 @@

Administration & Configuration

- - Warning: This section is intended for system administrators only. Make sure - you know what you are doing. - +

+ Warning: This section is intended for system administrators only. Make sure + you know what you are doing. +

-

+ +

Shortcuts

+ + + Site Settings + + + + Database Conflicts + + + +
+

Backup

-

+

diff --git a/src/app/core/common-components/view-title/view-title.component.scss b/src/app/core/common-components/view-title/view-title.component.scss index 652acdd382..f03d963386 100644 --- a/src/app/core/common-components/view-title/view-title.component.scss +++ b/src/app/core/common-components/view-title/view-title.component.scss @@ -3,4 +3,6 @@ flex-direction: row; align-items: center; margin-bottom: 0 !important; + + max-width: 100%; } diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 1355efea65..ea1144d0d1 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -42,16 +42,6 @@ export const defaultJsonConfig = { "icon": "tasks", "link": "/todo" }, - { - "name": $localize`:Menu item:Admin`, - "icon": "wrench", - "link": "/admin" - }, - { - "name": $localize`:Menu item:Site settings`, - "icon": "wrench", - "link": "/site-settings/global" - }, { "name": $localize`:Menu item:Import`, "icon": "file-import", @@ -67,16 +57,16 @@ export const defaultJsonConfig = { "icon": "line-chart", "link": "/report" }, - { - "name": $localize`:Menu item:Database Conflicts`, - "icon": "wrench", - "link": "/admin/conflicts" - }, { "name": $localize`:Menu item:Help`, "icon": "question", "link": "/help" }, + { + "name": $localize`:Menu item:Admin`, + "icon": "wrench", + "link": "/admin" + }, ] }, "view:": { @@ -258,43 +248,6 @@ export const defaultJsonConfig = { ] } }, - "view:site-settings/:id": { - "component": "EntityDetails", - "config": { - "entity": "SiteSettings", - "panels": [ - { - "title": $localize`Site Settings`, - "components": [ - { - "component": "Form", - "config": { - "fieldGroups": [ - { fields: ["logo", "favicon"] }, - { fields: ["siteName", "defaultLanguage", "displayLanguageSelect"] }, - { fields: ["primary", "secondary", "error", "font"] }, - ] - } - } - ] - } - ] - }, - "requiredPermissionOperation": "update", - "permittedUserRoles": ["admin_app"] - }, - "view:admin": { - "component": "Admin", - "permittedUserRoles": ["admin_app"] - }, - "view:admin/config-import": { - "component": "ConfigImport", - "permittedUserRoles": ["admin_app"] - }, - "view:admin/conflicts": { - "component": "ConflictResolution", - "permittedUserRoles": ["admin_app"] - }, "view:import": { "component": "Import", }, diff --git a/src/app/core/config/config.ts b/src/app/core/config/config.ts index bbadf68ed4..6b0dc1be3c 100644 --- a/src/app/core/config/config.ts +++ b/src/app/core/config/config.ts @@ -26,4 +26,10 @@ export class Config extends Entity { super(id); this.data = configuration; } + + override copy(): this { + const newConfig = super.copy(); + newConfig.data = JSON.parse(JSON.stringify(this.data)); + return newConfig; + } } diff --git a/src/app/core/config/dynamic-routing/router.service.ts b/src/app/core/config/dynamic-routing/router.service.ts index 238d3d8581..723bdcc49b 100644 --- a/src/app/core/config/dynamic-routing/router.service.ts +++ b/src/app/core/config/dynamic-routing/router.service.ts @@ -46,7 +46,8 @@ export class RouterService { for (const view of viewConfigs) { try { - routes.push(this.createRoute(view, additionalRoutes)); + const newRoute = this.createRoute(view, additionalRoutes); + routes.push(newRoute); } catch (e) { this.loggingService.warn( `Failed to create route for view ${view._id}: ${e.message}`, @@ -72,10 +73,10 @@ export class RouterService { private createRoute(view: ViewConfig, additionalRoutes: Route[]) { const path = view._id.substring(PREFIX_VIEW_CONFIG.length); - const route = additionalRoutes.find((r) => r.path === path); + const existingRoute = additionalRoutes.find((r) => r.path === path); - if (route) { - return this.generateRouteFromConfig(view, route); + if (existingRoute) { + return this.generateRouteFromConfig(view, existingRoute); } else { return this.generateRouteFromConfig(view, { path, @@ -86,7 +87,7 @@ export class RouterService { } private generateRouteFromConfig(view: ViewConfig, route: Route): Route { - route.data = route.data ?? {}; + route.data = { ...route?.data }; // data of currently active route is readonly, which can throw errors here route.canActivate = [AuthGuard, EntityPermissionGuard]; route.canDeactivate = [ () => inject(UnsavedChangesService).checkUnsavedChanges(), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 56aea073e2..ecf28c5739 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -5,7 +5,6 @@ import { CurrentUserSubject, User } from "./user/user"; import { Config } from "./config/config"; import { StringDatatype } from "./basic-datatypes/string/string.datatype"; import { DefaultDatatype } from "./entity/default-datatype/default.datatype"; -import { SchemaEmbedDatatype } from "./basic-datatypes/schema-embed/schema-embed.datatype"; import { ArrayDatatype } from "./basic-datatypes/array/array.datatype"; import { MapDatatype } from "./basic-datatypes/map/map.datatype"; import { MonthDatatype } from "./basic-datatypes/month/month.datatype"; @@ -19,6 +18,8 @@ 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"; +import { UpdateMetadataDatatype } from "./entity/model/update-metadata.datatype"; /** * Core module registering basic parts like datatypes and components. @@ -28,15 +29,16 @@ import { CommonModule } from "@angular/common"; CurrentUserSubject, // 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: UpdateMetadataDatatype, 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/dashboard/dashboard/dashboard.component.ts b/src/app/core/dashboard/dashboard/dashboard.component.ts index e0ca13a494..8552547edf 100644 --- a/src/app/core/dashboard/dashboard/dashboard.component.ts +++ b/src/app/core/dashboard/dashboard/dashboard.component.ts @@ -17,9 +17,9 @@ import { Component, Input } from "@angular/core"; import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; -import { RouteTarget } from "../../../app.routing"; import { NgFor } from "@angular/common"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("Dashboard") @Component({ diff --git a/src/app/core/demo-data/demo-data-generator.ts b/src/app/core/demo-data/demo-data-generator.ts index 7967f88815..6de3010041 100644 --- a/src/app/core/demo-data/demo-data-generator.ts +++ b/src/app/core/demo-data/demo-data-generator.ts @@ -1,7 +1,7 @@ import { Entity } from "../entity/model/entity"; /** - * Abstract base class for demo data generator services. + * Abstract base class for demo data generator backup. * * For usage refer to the How-To Guides: * - [How to Generate Demo Data]{@link /additional-documentation/how-to-guides/generate-demo-data.html} diff --git a/src/app/core/entity-details/EntityDetailsConfig.ts b/src/app/core/entity-details/EntityDetailsConfig.ts index cda678c938..16d9e5e6b3 100644 --- a/src/app/core/entity-details/EntityDetailsConfig.ts +++ b/src/app/core/entity-details/EntityDetailsConfig.ts @@ -38,7 +38,7 @@ export interface PanelComponent { /** * An optional second title for only this component. */ - title: string; + title?: string; /** * The name of the component. When registered, this usually is the name of the 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 0145da333f..3192b33ff7 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,19 @@ 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 f537ad38af..84ad5e9cef 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,11 +1,10 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { Router } from "@angular/router"; +import { Router, RouterLink } from "@angular/router"; import { Panel, PanelComponent, PanelConfig } from "../EntityDetailsConfig"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { AnalyticsService } from "../../analytics/analytics.service"; import { EntityAbility } from "../../permissions/ability/entity-ability"; -import { RouteTarget } from "../../../app.routing"; import { EntityRegistry } from "../../entity/database-entity.decorator"; import { MatButtonModule } from "@angular/material/button"; import { MatMenuModule } from "@angular/material/menu"; @@ -15,7 +14,7 @@ import { MatTabsModule } from "@angular/material/tabs"; import { TabStateModule } from "../../../utils/tab-state/tab-state.module"; import { MatTooltipModule } from "@angular/material/tooltip"; import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { NgForOf, NgIf } from "@angular/common"; +import { CommonModule, NgForOf, NgIf } from "@angular/common"; import { ViewTitleComponent } from "../../common-components/view-title/view-title.component"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; @@ -26,6 +25,8 @@ import { EntityArchivedInfoComponent } from "../entity-archived-info/entity-arch import { filter } from "rxjs/operators"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { Subscription } from "rxjs"; +import { AbilityModule } from "@casl/angular"; +import { RouteTarget } from "../../../route-target"; /** * This component can be used to display an entity in more detail. @@ -56,6 +57,9 @@ import { Subscription } from "rxjs"; DisableEntityOperationDirective, EntityActionsMenuComponent, EntityArchivedInfoComponent, + RouterLink, + AbilityModule, + CommonModule, ], }) export class EntityDetailsComponent implements OnChanges { diff --git a/src/app/core/entity-details/form/form.component.ts b/src/app/core/entity-details/form/form.component.ts index a795126af3..bc6e7d2da3 100644 --- a/src/app/core/entity-details/form/form.component.ts +++ b/src/app/core/entity-details/form/form.component.ts @@ -32,7 +32,7 @@ import { FieldGroup } from "./field-group"; ], standalone: true, }) -export class FormComponent implements OnInit { +export class FormComponent implements FormConfig, OnInit { @Input() entity: E; @Input() creatingNew = false; @@ -83,3 +83,10 @@ export class FormComponent implements OnInit { this.form.disable(); } } + +/** + * Config format that the FormComponent handles. + */ +export interface FormConfig { + fieldGroups: FieldGroup[]; +} 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 6b8eab4254..eaa40dbf99 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 @@ -20,7 +20,6 @@ import { FormFieldConfig } from "../../common-components/entity-form/entity-form import { EntitySubrecordComponent } from "../../common-components/entity-subrecord/entity-subrecord/entity-subrecord.component"; import { entityFilterPredicate } from "../../filter/filter-generator/filter-predicate"; import { AnalyticsService } from "../../analytics/analytics.service"; -import { RouteTarget } from "../../../app.routing"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { EntityRegistry } from "../../entity/database-entity.decorator"; import { ScreenWidthObserver } from "../../../utils/media/screen-size-observer.service"; @@ -46,6 +45,7 @@ import { DuplicateRecordService } from "../duplicate-records/duplicate-records.s import { MatTooltipModule } from "@angular/material/tooltip"; import { Sort } from "@angular/material/sort"; import { ExportColumnConfig } from "../../export/data-transformation-service/export-column-config"; +import { RouteTarget } from "../../../route-target"; /** * This component allows to create a full-blown table with pagination, filtering, searching and grouping. diff --git a/src/app/core/entity/database-entity.decorator.ts b/src/app/core/entity/database-entity.decorator.ts index 8c6914ce63..72ba1b3682 100644 --- a/src/app/core/entity/database-entity.decorator.ts +++ b/src/app/core/entity/database-entity.decorator.ts @@ -2,7 +2,24 @@ import { Entity, EntityConstructor } from "./model/entity"; import { Registry } from "../config/registry/dynamic-registry"; import { getEntitySchema } from "./database-field.decorator"; -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/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/core/entity/entity-actions/entity-actions.service.ts b/src/app/core/entity/entity-actions/entity-actions.service.ts index ad44a37fcf..60260a7972 100644 --- a/src/app/core/entity/entity-actions/entity-actions.service.ts +++ b/src/app/core/entity/entity-actions/entity-actions.service.ts @@ -26,7 +26,7 @@ export class EntityActionsService { private entityAnonymize: EntityAnonymizeService, ) {} - private showSnackbarConfirmation( + showSnackbarConfirmationWithUndo( entity: Entity, action: string, previousEntitiesForUndo: Entity[], @@ -109,7 +109,7 @@ export class EntityActionsService { await this.router.navigate([parentUrl]); } - this.showSnackbarConfirmation( + this.showSnackbarConfirmationWithUndo( result.originalEntitiesBeforeChange[0], $localize`:Entity action confirmation message verb:Deleted`, result.originalEntitiesBeforeChange, @@ -158,7 +158,7 @@ export class EntityActionsService { ); } - this.showSnackbarConfirmation( + this.showSnackbarConfirmationWithUndo( result.originalEntitiesBeforeChange[0], $localize`:Entity action confirmation message verb:Anonymized`, result.originalEntitiesBeforeChange, @@ -173,9 +173,10 @@ export class EntityActionsService { async archive(entity: E) { const originalEntity = entity.copy(); entity.inactive = true; + await this.entityMapper.save(entity); - this.showSnackbarConfirmation( + this.showSnackbarConfirmationWithUndo( originalEntity, $localize`:Entity action confirmation message verb:Archived`, [originalEntity], @@ -191,7 +192,7 @@ export class EntityActionsService { entity.inactive = false; await this.entityMapper.save(entity); - this.showSnackbarConfirmation( + this.showSnackbarConfirmationWithUndo( originalEntity, $localize`:Entity action confirmation message verb:Reactivated`, [originalEntity], diff --git a/src/app/core/entity/entity-actions/entity-delete.service.spec.ts b/src/app/core/entity/entity-actions/entity-delete.service.spec.ts index 182f37cf43..fb475b56c7 100644 --- a/src/app/core/entity/entity-actions/entity-delete.service.spec.ts +++ b/src/app/core/entity/entity-actions/entity-delete.service.spec.ts @@ -17,6 +17,8 @@ import { import { expectEntitiesToMatch } from "../../../utils/expect-entity-data.spec"; import { Note } from "../../../child-dev-project/notes/model/note"; import { Child } from "../../../child-dev-project/children/model/child"; +import { DefaultDatatype } from "../default-datatype/default.datatype"; +import { EventAttendanceDatatype } from "../../../child-dev-project/attendance/model/event-attendance.datatype"; describe("EntityDeleteService", () => { let service: EntityDeleteService; @@ -30,6 +32,11 @@ describe("EntityDeleteService", () => { providers: [ EntityDeleteService, { provide: EntityMapperService, useValue: entityMapper }, + { + provide: DefaultDatatype, + useClass: EventAttendanceDatatype, + multi: true, + }, ], }); diff --git a/src/app/core/entity/entity-config.service.ts b/src/app/core/entity/entity-config.service.ts index 0f79e82100..9bdc2acfbc 100644 --- a/src/app/core/entity/entity-config.service.ts +++ b/src/app/core/entity/entity-config.service.ts @@ -5,6 +5,7 @@ import { EntityRegistry } from "./database-entity.decorator"; import { IconName } from "@fortawesome/fontawesome-svg-core"; import { EntityConfig } from "./entity-config"; import { addPropertySchema } from "./database-field.decorator"; +import { PREFIX_VIEW_CONFIG } from "../config/dynamic-routing/view-config.interface"; /** * A service that allows to work with configuration-objects @@ -18,6 +19,12 @@ export class EntityConfigService { /** @deprecated will become private, use the service to access the data */ static readonly PREFIX_ENTITY_CONFIG = "entity:"; + static getDetailsViewId(entityConstructor: EntityConstructor) { + return ( + PREFIX_VIEW_CONFIG + entityConstructor.route.replace(/^\//, "") + "/:id" + ); + } + // TODO: merge with EntityRegistry? constructor( @@ -71,6 +78,7 @@ export class EntityConfigService { ) { const entityConfig = configAttributes || this.getEntityConfig(entityType); for (const [key, value] of Object.entries(entityConfig?.attributes ?? {})) { + value._isCustomizedField = true; addPropertySchema(entityType.prototype, key, value); } diff --git a/src/app/core/entity/entity-mapper/entity-mapper.service.spec.ts b/src/app/core/entity/entity-mapper/entity-mapper.service.spec.ts index 510adb38d7..16b0b584fb 100644 --- a/src/app/core/entity/entity-mapper/entity-mapper.service.spec.ts +++ b/src/app/core/entity/entity-mapper/entity-mapper.service.spec.ts @@ -20,7 +20,6 @@ import { Entity } from "../model/entity"; import { TestBed, waitForAsync } from "@angular/core/testing"; import { PouchDatabase } from "../../database/pouch-database"; import { DatabaseEntity } from "../database-entity.decorator"; -import { Child } from "../../../child-dev-project/children/model/child"; import { Database } from "../../database/database"; import { TEST_USER } from "../../../utils/mock-local-session"; import { CurrentUserSubject } from "../../user/user"; @@ -270,17 +269,6 @@ describe("EntityMapperService", () => { ]); }); - it("should include _id field in transformation errors", (done) => { - const doc = { _id: "Child:test", dateOfBirth: "invalidDate" }; - testDatabase - .put(doc) - .then(() => entityMapper.load(Child, "Child:test")) - .catch((err) => { - expect(err.message).toContain("Child:test"); - done(); - }); - }); - it("sets the entityCreated property on save if it is a new entity & entityUpdated on subsequent saves", async () => { jasmine.clock().install(); TestBed.inject(CurrentUserSubject).next({ diff --git a/src/app/core/entity/model/entity.ts b/src/app/core/entity/model/entity.ts index 1bf954d818..50fd05b385 100644 --- a/src/app/core/entity/model/entity.ts +++ b/src/app/core/entity/model/entity.ts @@ -184,15 +184,11 @@ export class Entity { @DatabaseField({ anonymize: "retain" }) _rev: string; @DatabaseField({ - dataType: "schema-embed", - additional: UpdateMetadata, anonymize: "retain", }) created: UpdateMetadata; @DatabaseField({ - dataType: "schema-embed", - additional: UpdateMetadata, anonymize: "retain", }) updated: UpdateMetadata; diff --git a/src/app/core/entity/model/update-metadata.datatype.spec.ts b/src/app/core/entity/model/update-metadata.datatype.spec.ts new file mode 100644 index 0000000000..591f7c1c31 --- /dev/null +++ b/src/app/core/entity/model/update-metadata.datatype.spec.ts @@ -0,0 +1,23 @@ +import { testDatatype } from "../schema/entity-schema.service.spec"; +import { UpdateMetadataDatatype } from "./update-metadata.datatype"; +import { UpdateMetadata } from "./update-metadata"; +import moment from "moment"; +import { DefaultDatatype } from "../default-datatype/default.datatype"; +import { DateDatatype } from "../../basic-datatypes/date/date.datatype"; +import { StringDatatype } from "../../basic-datatypes/string/string.datatype"; + +describe("Schema data type: update-metadata", () => { + testDatatype( + UpdateMetadataDatatype, + new UpdateMetadata("tester", moment("2023-12-06T14:25:43.976Z").toDate()), + { + by: "tester", + at: moment("2023-12-06T14:25:43.976Z").toDate(), // DateDatatype currently does not explicitly convert to ISO or any database format + }, + undefined, + [ + { provide: DefaultDatatype, useClass: DateDatatype, multi: true }, + { provide: DefaultDatatype, useClass: StringDatatype, multi: true }, + ], + ); +}); diff --git a/src/app/core/entity/model/update-metadata.datatype.ts b/src/app/core/entity/model/update-metadata.datatype.ts new file mode 100644 index 0000000000..cfa3942480 --- /dev/null +++ b/src/app/core/entity/model/update-metadata.datatype.ts @@ -0,0 +1,19 @@ +import { UpdateMetadata } from "./update-metadata"; +import { SchemaEmbedDatatype } from "../../basic-datatypes/schema-embed/schema-embed.datatype"; +import { EntityConstructor } from "./entity"; +import { Injectable } from "@angular/core"; +import { EntitySchemaService } from "../schema/entity-schema.service"; + +/** + * Datatype for internally saved meta-data of entity edits. + */ +@Injectable() +export class UpdateMetadataDatatype extends SchemaEmbedDatatype { + static override dataType = UpdateMetadata.DATA_TYPE; + + override embeddedType = UpdateMetadata as unknown as EntityConstructor; + + constructor(schemaService: EntitySchemaService) { + super(schemaService); + } +} diff --git a/src/app/core/entity/model/update-metadata.ts b/src/app/core/entity/model/update-metadata.ts index 4f9a58eade..b8b96efa8f 100644 --- a/src/app/core/entity/model/update-metadata.ts +++ b/src/app/core/entity/model/update-metadata.ts @@ -4,13 +4,15 @@ import { DatabaseField } from "../database-field.decorator"; * Object to store metadata about a "revision" of a document including date and author of the change. */ export class UpdateMetadata { + static DATA_TYPE = "update-metadata"; + /** when the update was saved to db */ @DatabaseField() at: Date; /** username who saved the update */ @DatabaseField() by: string; - constructor(by: string, at: Date = new Date()) { + constructor(by: string = undefined, at: Date = new Date()) { this.by = by; this.at = at; } diff --git a/src/app/core/entity/schema/entity-schema-field.ts b/src/app/core/entity/schema/entity-schema-field.ts index d57f9630fa..f0683a9dae 100644 --- a/src/app/core/entity/schema/entity-schema-field.ts +++ b/src/app/core/entity/schema/entity-schema-field.ts @@ -127,6 +127,14 @@ export interface EntitySchemaField { * "retain-anonymized" triggers a special dataType action to retain the data partially in a special, anonymized form. */ anonymize?: "retain" | "retain-anonymized"; + + /** + * indicates that this field has been created/modified through the config system + * and is not matching the original entity class definition. + * + * Set automatically during config initialization. + */ + _isCustomizedField?: boolean; } /** diff --git a/src/app/core/entity/schema/entity-schema.service.spec.ts b/src/app/core/entity/schema/entity-schema.service.spec.ts index f4d7ec9ff0..bd5f59455d 100644 --- a/src/app/core/entity/schema/entity-schema.service.spec.ts +++ b/src/app/core/entity/schema/entity-schema.service.spec.ts @@ -184,45 +184,65 @@ describe("EntitySchemaService", () => { }); }); -export function testDatatype( - dataType: DefaultDatatype, +export function testDatatype( + dataType: D | (new (params: any) => D), objectValue, databaseValue, additionalSchemaFieldConfig?: any, + additionalProviders?: any[], ) { let entitySchemaService: EntitySchemaService; - let mockInjector: jasmine.SpyObj; - beforeEach(waitForAsync(() => { - mockInjector = jasmine.createSpyObj(["get"]); - mockInjector.get.and.returnValue([dataType]); - - entitySchemaService = new EntitySchemaService(mockInjector); - })); + describe("test datatype", () => { + beforeEach(waitForAsync(() => { + additionalProviders = additionalProviders || []; + if (dataType instanceof DefaultDatatype) { + additionalProviders.push({ + provide: DefaultDatatype, + useValue: dataType, + multi: true, + }); + } else { + additionalProviders.push({ + provide: DefaultDatatype, + useClass: dataType, + multi: true, + }); + } + + TestBed.configureTestingModule({ + providers: [EntitySchemaService, ...additionalProviders], + }); + + entitySchemaService = TestBed.inject(EntitySchemaService); + })); - class TestEntity extends Entity { - @DatabaseField({ - dataType: dataType.dataType, - additional: additionalSchemaFieldConfig, - }) - field; - } + class TestEntity extends Entity { + @DatabaseField({ + dataType: (dataType as DefaultDatatype | typeof DefaultDatatype) + .dataType, + additional: additionalSchemaFieldConfig, + }) + field; + } - it("should convert to database format", () => { - const entity = new TestEntity(); - entity.field = objectValue; + it("should convert to database format", () => { + const entity = new TestEntity(); + entity.field = objectValue; - const rawData = entitySchemaService.transformEntityToDatabaseFormat(entity); - expect(rawData.field).toEqual(databaseValue); - }); + const rawData = + entitySchemaService.transformEntityToDatabaseFormat(entity); + expect(rawData.field).toEqual(databaseValue); + }); - it("should convert from database to entity format", () => { - const data = { - field: databaseValue, - }; - const loadedEntity = new TestEntity(); - entitySchemaService.loadDataIntoEntity(loadedEntity, data); + it("should convert from database to entity format", () => { + const data = { + field: databaseValue, + }; + const loadedEntity = new TestEntity(); + entitySchemaService.loadDataIntoEntity(loadedEntity, data); - expect(loadedEntity.field).toEqual(objectValue); + expect(loadedEntity.field).toEqual(objectValue); + }); }); } 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 c45574a155..8c72ee576f 100644 --- a/src/app/core/export/download-service/download.service.spec.ts +++ b/src/app/core/export/download-service/download.service.spec.ts @@ -90,15 +90,15 @@ describe("DownloadService", () => { }; const testDate = "2020-01-30"; - @DatabaseEntity("TestEntity") - class TestEntity extends Entity { + @DatabaseEntity("ValuesDownloadTestEntity") + class ValuesDownloadTestEntity extends Entity { @DatabaseField({ label: "test enum" }) enumProperty: ConfigurableEnumValue; @DatabaseField({ label: "test date" }) dateProperty: Date; @DatabaseField({ label: "test boolean" }) boolProperty: boolean; } - const testEntity = new TestEntity(); + const testEntity = new ValuesDownloadTestEntity(); testEntity.enumProperty = testEnumValue; testEntity.dateProperty = moment(testDate).toDate(); testEntity.boolProperty = true; @@ -135,19 +135,19 @@ describe("DownloadService", () => { 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 { + @DatabaseEntity("LabelDownloadTestEntity") + class LabelDownloadTestEntity extends Entity { @DatabaseField({ label: "test string" }) stringProperty: string; @DatabaseField({ label: "test date" }) otherProperty: string; @DatabaseField() boolProperty: boolean; } - const labelTestEntity = new LabelTestEntity(); + const labelTestEntity = new LabelDownloadTestEntity(); labelTestEntity.stringProperty = testString; labelTestEntity.otherProperty = "x"; labelTestEntity.boolProperty = true; - const incompleteTestEntity = new LabelTestEntity(); + const incompleteTestEntity = new LabelDownloadTestEntity(); incompleteTestEntity.otherProperty = "second row"; const csvExport = await service.createCsv([ diff --git a/src/app/core/export/download-service/download.service.ts b/src/app/core/export/download-service/download.service.ts index 57fe6180fc..2f78122c73 100644 --- a/src/app/core/export/download-service/download.service.ts +++ b/src/app/core/export/download-service/download.service.ts @@ -63,7 +63,7 @@ export class DownloadService { switch (format.toLowerCase()) { case "json": - result = JSON.stringify(data); // TODO: support exportConfig for json format + result = typeof data === "string" ? data : JSON.stringify(data); // TODO: support exportConfig for json format return new Blob([result], { type: "application/json" }); case "csv": result = await this.createCsv(data); 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/core/import/import/import.component.ts b/src/app/core/import/import/import.component.ts index a21d7bb500..78322f9216 100644 --- a/src/app/core/import/import/import.component.ts +++ b/src/app/core/import/import/import.component.ts @@ -20,8 +20,8 @@ import { ImportAdditionalActionsComponent } from "../import-additional-actions/i import { MatButtonModule } from "@angular/material/button"; import { ImportColumnMappingComponent } from "../import-column-mapping/import-column-mapping.component"; import { ImportReviewDataComponent } from "../import-review-data/import-review-data.component"; -import { RouteTarget } from "../../../app.routing"; import { LOCATION_TOKEN } from "../../../utils/di-tokens"; +import { RouteTarget } from "../../../route-target"; /** * View providing a full UI workflow to import data from an uploaded file. diff --git a/src/app/core/permissions/ability/ability.service.spec.ts b/src/app/core/permissions/ability/ability.service.spec.ts index eebbe67852..78e435ceec 100644 --- a/src/app/core/permissions/ability/ability.service.spec.ts +++ b/src/app/core/permissions/ability/ability.service.spec.ts @@ -16,6 +16,8 @@ import { UpdatedEntity } from "../../entity/model/entity-update"; import { mockEntityMapper } from "../../entity/entity-mapper/mock-entity-mapper-service"; import { TEST_USER } from "../../../utils/mock-local-session"; import { CoreTestingModule } from "../../../utils/core-testing.module"; +import { DefaultDatatype } from "../../entity/default-datatype/default.datatype"; +import { EventAttendanceDatatype } from "../../../child-dev-project/attendance/model/event-attendance.datatype"; describe("AbilityService", () => { let service: AbilityService; @@ -48,6 +50,11 @@ describe("AbilityService", () => { roles: ["user_app"], }), }, + { + provide: DefaultDatatype, + useClass: EventAttendanceDatatype, + multi: true, + }, { provide: PermissionEnforcerService, useValue: jasmine.createSpyObj(["enforcePermissionsOnLocalData"]), diff --git a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts index 2a70d6e588..98246edda2 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; import { UserRoleGuard } from "./user-role.guard"; import { RouterTestingModule } from "@angular/router/testing"; -import { ActivatedRouteSnapshot, Router } from "@angular/router"; +import { ActivatedRouteSnapshot, Route, Router } from "@angular/router"; import { AuthUser } from "../../session/auth/auth-user"; import { ConfigService } from "../../config/config.service"; import { PREFIX_VIEW_CONFIG } from "../../config/dynamic-routing/view-config.interface"; @@ -110,4 +110,35 @@ describe("UserRoleGuard", () => { expect(guard.checkRoutePermissions("pathA")).toBeTrue(); expect(guard.checkRoutePermissions("pathA/1")).toBeTrue(); }); + + it("should checkRoutePermissions considering nested child routes", () => { + const nestedRoute: Route = { + path: "nested", + children: [ + { path: "", data: { permittedUserRoles: ["admin"] } }, + { path: "X", data: {} }, + ], + }; + const onParentRoute: Route = { + path: "on-parent", + children: [{ path: "" }, { path: "X" }], + data: { permittedUserRoles: ["admin"] }, + }; + + const router = TestBed.inject(Router); + router.config.push(nestedRoute); + router.config.push(onParentRoute); + + userSubject.next(normalUser); + expect(guard.checkRoutePermissions("nested")).toBeFalse(); + expect(guard.checkRoutePermissions("nested/X")).toBeTrue(); + expect(guard.checkRoutePermissions("on-parent")).toBeFalse(); + expect(guard.checkRoutePermissions("on-parent/X")).toBeFalse(); + + userSubject.next(adminUser); + expect(guard.checkRoutePermissions("nested")).toBeTrue(); + expect(guard.checkRoutePermissions("nested/X")).toBeTrue(); + expect(guard.checkRoutePermissions("on-parent")).toBeTrue(); + expect(guard.checkRoutePermissions("on-parent/X")).toBeTrue(); + }); }); diff --git a/src/app/core/permissions/permission-guard/user-role.guard.ts b/src/app/core/permissions/permission-guard/user-role.guard.ts index 45db58e8ba..dcca95d56e 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.ts @@ -1,5 +1,10 @@ import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router"; +import { + ActivatedRouteSnapshot, + CanActivate, + Route, + Router, +} from "@angular/router"; import { PREFIX_VIEW_CONFIG, RouteData, @@ -48,15 +53,12 @@ export class UserRoleGuard implements CanActivate { // removing leading slash path = path.replace(/^\//, ""); - let viewConfig = this.configService.getConfig( - PREFIX_VIEW_CONFIG + path, - ); + let viewConfig = this.getRouteConfig(path); + if (!viewConfig) { // search for details route ("path/:id" for any id) const detailsPath = path.replace(/\/[^\/]*$/, "/:id"); - viewConfig = this.configService.getConfig( - PREFIX_VIEW_CONFIG + detailsPath, - ); + viewConfig = this.getRouteConfig(detailsPath); } return this.canActivate({ @@ -64,4 +66,48 @@ export class UserRoleGuard implements CanActivate { data: { permittedUserRoles: viewConfig?.permittedUserRoles }, } as any); } + + /** + * Find the relevant ViewConfig from config or already registered routes + * @param path + * @private + */ + private getRouteConfig(path: string): ViewConfig { + const viewConfig = this.configService.getConfig( + PREFIX_VIEW_CONFIG + path, + ); + if (viewConfig) { + return viewConfig; + } + + let route = this.getRouteDataFromRouter(path, this.router.config); + return route?.data as ViewConfig; + } + + /** + * Extract the relevant route from Router, to get a merged route that contains the full trail of `permittedRoles` + * @param path + * @param routes + * @private + */ + private getRouteDataFromRouter(path: string, routes: Route[]) { + const pathSections = path.split("/"); + let route = routes.find((r) => r.path === path); + if (!route && pathSections.length > 1) { + route = routes.find((r) => r.path === pathSections[0]); + } + + if (route?.children) { + const childRoute = this.getRouteDataFromRouter( + pathSections.slice(1).join("/"), + route.children, + ); + if (childRoute) { + childRoute.data = { ...route.data, ...childRoute?.data }; + route = childRoute; + } + } + + return route; + } } diff --git a/src/app/core/support/support/support.component.spec.ts b/src/app/core/support/support/support.component.spec.ts index bad0740582..561f086714 100644 --- a/src/app/core/support/support/support.component.spec.ts +++ b/src/app/core/support/support/support.component.spec.ts @@ -16,7 +16,7 @@ import { MatDialogModule } from "@angular/material/dialog"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { PouchDatabase } from "../../database/pouch-database"; -import { BackupService } from "../../../features/admin/services/backup.service"; +import { BackupService } from "../../admin/backup/backup.service"; import { DownloadService } from "../../export/download-service/download.service"; import { TEST_USER } from "../../../utils/mock-local-session"; import { SyncService } from "../../database/sync.service"; diff --git a/src/app/core/support/support/support.component.ts b/src/app/core/support/support/support.component.ts index 9f442f8465..ad7b0180e7 100644 --- a/src/app/core/support/support/support.component.ts +++ b/src/app/core/support/support/support.component.ts @@ -12,7 +12,7 @@ import { MatExpansionModule } from "@angular/material/expansion"; import { MatButtonModule } from "@angular/material/button"; import { PouchDatabase } from "../../database/pouch-database"; import { MatTooltipModule } from "@angular/material/tooltip"; -import { BackupService } from "../../../features/admin/services/backup.service"; +import { BackupService } from "../../admin/backup/backup.service"; import { DownloadService } from "../../export/download-service/download.service"; import { SyncStateSubject } from "../../session/session-type"; import { SyncService } from "../../database/sync.service"; diff --git a/src/app/core/ui/routed-view/routed-view.component.ts b/src/app/core/ui/routed-view/routed-view.component.ts index 20b5ceb3e2..82af3120b6 100644 --- a/src/app/core/ui/routed-view/routed-view.component.ts +++ b/src/app/core/ui/routed-view/routed-view.component.ts @@ -1,9 +1,9 @@ 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"; +import { RouteTarget } from "../../../route-target"; /** * Wrapper component for a primary, full page view diff --git a/src/app/core/ui/ui/ui.component.global-styles.scss b/src/app/core/ui/ui/ui.component.global-styles.scss index fedd6db36e..1fe8cc47fc 100644 --- a/src/app/core/ui/ui/ui.component.global-styles.scss +++ b/src/app/core/ui/ui/ui.component.global-styles.scss @@ -18,3 +18,8 @@ display: block; box-sizing: border-box; } + +.form-field-icon-suffix { + cursor: pointer; + padding: 0 sizes.$small; +} diff --git a/src/app/features/admin/admin.module.ts b/src/app/features/admin/admin.module.ts deleted file mode 100644 index c01c2e31b4..0000000000 --- a/src/app/features/admin/admin.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NgModule } from "@angular/core"; -import { ComponentRegistry } from "../../dynamic-components"; - -/** - * Basic UI to make admin / technical debugging functions accessible. - */ -@NgModule({}) -export class AdminModule { - constructor(components: ComponentRegistry) { - components.addAll([ - [ - "Admin", - () => import("./admin/admin.component").then((c) => c.AdminComponent), - ], - ]); - } -} diff --git a/src/app/features/coming-soon/coming-soon/coming-soon.component.ts b/src/app/features/coming-soon/coming-soon/coming-soon.component.ts index a9aa119bd2..c6cf2215d3 100644 --- a/src/app/features/coming-soon/coming-soon/coming-soon.component.ts +++ b/src/app/features/coming-soon/coming-soon/coming-soon.component.ts @@ -7,7 +7,8 @@ import { DialogCloseComponent } from "../../../core/common-components/dialog-clo import { MatButtonModule } from "@angular/material/button"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { NgIf } from "@angular/common"; -import { RouteTarget } from "../../../app.routing"; + +import { RouteTarget } from "../../../route-target"; /** * Placeholder page to announce that a feature is not available yet. diff --git a/src/app/features/config-setup/config-import-parser.service.spec.ts b/src/app/features/config-setup/config-import-parser.service.spec.ts index 71ea8cfdee..e514a45905 100644 --- a/src/app/features/config-setup/config-import-parser.service.spec.ts +++ b/src/app/features/config-setup/config-import-parser.service.spec.ts @@ -131,25 +131,6 @@ describe("ConfigImportParserService", () => { ); }); - it("should generate sensible ids from labels", () => { - const labelIdPairs = [ - ["name", "name"], - ["Name", "name"], - ["FirstName", "firstName"], - ["name of", "nameOf"], - ["test's name", "testsName"], - ["name 123", "name123"], - ["123 name", "123Name"], // this is possible in JavaScript - ]; - - for (const testCase of labelIdPairs) { - const generatedId = ConfigImportParserService.generateIdFromLabel( - testCase[0], - ); - expect(generatedId).toBe(testCase[1]); - } - }); - it("should generate enum from additional column and reuse if already exists", () => { const parsedConfig = expectToBeParsedIntoEntityConfig( [ diff --git a/src/app/features/config-setup/config-import-parser.service.ts b/src/app/features/config-setup/config-import-parser.service.ts index 3069ffe0b1..84d7dcad3c 100644 --- a/src/app/features/config-setup/config-import-parser.service.ts +++ b/src/app/features/config-setup/config-import-parser.service.ts @@ -15,6 +15,7 @@ import { ViewConfig } from "../../core/config/dynamic-routing/view-config.interf import { defaultJsonConfig } from "../../core/config/config-fix"; import { EntityConfig } from "../../core/entity/entity-config"; import { EntityConfigService } from "../../core/entity/entity-config.service"; +import { generateIdFromLabel } from "../../utils/generate-id-from-label/generate-id-from-label"; @Injectable({ providedIn: "root", @@ -101,9 +102,7 @@ export class ConfigImportParserService { fieldDef: ConfigFieldRaw, entityType: string, ): { id: string; schema: EntitySchemaField } { - const fieldId = - fieldDef.id ?? - ConfigImportParserService.generateIdFromLabel(fieldDef.label); + const fieldId = fieldDef.id ?? generateIdFromLabel(fieldDef.label); const schema: EntitySchemaField = { dataType: fieldDef.dataType, @@ -149,19 +148,6 @@ export class ConfigImportParserService { return { id: fieldId, schema: schema }; } - /** - * Create a camelCase string out of any given string, so that it can be used as an id. - * @param label The input string to be transformed - */ - public static generateIdFromLabel(label: string) { - return label - .replace(/[^a-zA-Z0-9\s]/g, "") - .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { - return index === 0 ? word.toLowerCase() : word.toUpperCase(); - }) - .replace(/\s/g, ""); - } - /** * Parse a comma-separated list of enum values * and either create a new configurable-enum config or match an existing one that has the same options. diff --git a/src/app/features/config-setup/config-import/config-import.component.ts b/src/app/features/config-setup/config-import/config-import.component.ts index 4b3637aae6..201ef86d05 100644 --- a/src/app/features/config-setup/config-import/config-import.component.ts +++ b/src/app/features/config-setup/config-import/config-import.component.ts @@ -1,5 +1,4 @@ import { Component } from "@angular/core"; -import { RouteTarget } from "../../../app.routing"; import { InputFileComponent, ParsedData, @@ -10,6 +9,7 @@ import { FormsModule } from "@angular/forms"; import { MatInputModule } from "@angular/material/input"; import { MatButtonModule } from "@angular/material/button"; import { ClipboardModule } from "@angular/cdk/clipboard"; +import { RouteTarget } from "../../../route-target"; /** * UI to upload a config definition and generate a new app `Config` from the imported file. diff --git a/src/app/features/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts b/src/app/features/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts index 5956380d00..c01ea560b0 100644 --- a/src/app/features/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts +++ b/src/app/features/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts @@ -4,12 +4,12 @@ import { QueryDataSource } from "../../../core/database/query-data-source"; import { Entity } from "../../../core/entity/model/entity"; import { Database } from "../../../core/database/database"; import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; -import { RouteTarget } from "../../../app.routing"; import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { MatTableModule } from "@angular/material/table"; import { MatSortModule } from "@angular/material/sort"; import { CompareRevComponent } from "../compare-rev/compare-rev.component"; +import { RouteTarget } from "../../../route-target"; /** * List all document conflicts and allow the user to expand for details and manual resolution. 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.spec.ts b/src/app/features/location/location.datatype.spec.ts index af13b42310..2623453e6f 100644 --- a/src/app/features/location/location.datatype.spec.ts +++ b/src/app/features/location/location.datatype.spec.ts @@ -20,6 +20,18 @@ describe("Schema data type: location", () => { service = TestBed.inject(LocationDatatype); }); + it("should only return Geo objects when transforming from database to object format", async () => { + const mockGeoResult: GeoResult = { + lat: 1, + lon: 2, + display_name: "test address", + }; + expect(service.transformToObjectFormat(mockGeoResult)).toEqual( + mockGeoResult, + ); + expect(service.transformToObjectFormat(123 as any)).toBeUndefined(); + }); + async function testImportMapping( importedValue: string, mockedLookup: GeoResult[], diff --git a/src/app/features/location/location.datatype.ts b/src/app/features/location/location.datatype.ts index a68a0392e3..de529e7da6 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"; @@ -14,6 +15,15 @@ export class LocationDatatype extends DefaultDatatype { super(); } + transformToObjectFormat(value: GeoResult): GeoResult { + if (typeof value !== "object") { + // until we have an extended location datatype that includes a custom address addition field, discard invalid values (e.g. in case datatype was changed) + return undefined; + } + + return value; + } + async importMapFunction(val: any): Promise { if (!val) { return undefined; 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 aeba29b681..e97d247682 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 @@ -16,8 +16,8 @@ */ import { Component, Input } from "@angular/core"; -import { RouteTarget } from "../../../app.routing"; import { MarkdownPageModule } from "../markdown-page.module"; +import { RouteTarget } from "../../../route-target"; /** * Display markdown formatted page that is dynamically loaded based on the file defined in config. diff --git a/src/app/features/matching-entities/matching-entities/matching-entities.component.ts b/src/app/features/matching-entities/matching-entities/matching-entities.component.ts index a6d6db922c..9e20f85282 100644 --- a/src/app/features/matching-entities/matching-entities/matching-entities.component.ts +++ b/src/app/features/matching-entities/matching-entities/matching-entities.component.ts @@ -19,7 +19,6 @@ import { ColumnConfig, DataFilter, } from "../../../core/common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; -import { RouteTarget } from "../../../app.routing"; import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; import { ActivatedRoute } from "@angular/router"; import { FormDialogService } from "../../../core/form-dialog/form-dialog.service"; @@ -42,6 +41,7 @@ import { getLocationProperties } from "../../location/map-utils"; import { FlattenArrayPipe } from "../../../utils/flatten-array/flatten-array.pipe"; import { isArrayDataType } from "../../../core/basic-datatypes/datatype-utils"; import { FormFieldConfig } from "../../../core/common-components/entity-form/entity-form/FormConfig"; +import { RouteTarget } from "../../../route-target"; export interface MatchingSide extends MatchingSideConfig { /** pass along filters from app-filter to subrecord component */ diff --git a/src/app/features/reporting/reporting/reporting.component.ts b/src/app/features/reporting/reporting/reporting.component.ts index eee935032a..1e9dead572 100644 --- a/src/app/features/reporting/reporting/reporting.component.ts +++ b/src/app/features/reporting/reporting/reporting.component.ts @@ -9,7 +9,6 @@ import { } from "../report-row"; import moment from "moment"; import { ExportColumnConfig } from "../../../core/export/data-transformation-service/export-column-config"; -import { RouteTarget } from "../../../app.routing"; import { NgIf } from "@angular/common"; import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; import { SelectReportComponent } from "./select-report/select-report.component"; @@ -18,6 +17,7 @@ import { ObjectTableComponent } from "./object-table/object-table.component"; import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; import { ReportConfig } from "../report-config"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("Reporting") @Component({ 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"; 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 e781c3fab3..c1d35d9501 100644 --- a/src/app/features/todos/todo-list/todo-list.component.ts +++ b/src/app/features/todos/todo-list/todo-list.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Todo } from "../model/todo"; -import { RouteTarget } from "../../../app.routing"; import { EntityListConfig, PrebuiltFilterConfig, @@ -14,6 +13,7 @@ import moment from "moment"; import { EntityListComponent } from "../../../core/entity-list/entity-list/entity-list.component"; import { FilterSelectionOption } from "../../../core/filter/filters/filters"; import { CurrentUserSubject } from "../../../core/user/user"; +import { RouteTarget } from "../../../route-target"; @RouteTarget("TodoList") @Component({ diff --git a/src/app/route-target.ts b/src/app/route-target.ts new file mode 100644 index 0000000000..efafffb565 --- /dev/null +++ b/src/app/route-target.ts @@ -0,0 +1,9 @@ +/** + * Marks a class to be the target when routing. + * Use this by adding the annotation `@RouteTarget("...")` to a component. + * The name provided to the annotation can then be used in the configuration. + * + * IMPORTANT: + * The component also needs to be added to the `...Components` list of the respective module. + */ +export const RouteTarget = (_name: string) => (_) => undefined; diff --git a/src/app/utils/generate-id-from-label/generate-id-from-label.spec.ts b/src/app/utils/generate-id-from-label/generate-id-from-label.spec.ts new file mode 100644 index 0000000000..efdae5ba23 --- /dev/null +++ b/src/app/utils/generate-id-from-label/generate-id-from-label.spec.ts @@ -0,0 +1,22 @@ +import { generateIdFromLabel } from "./generate-id-from-label"; + +describe("generateIdFromLabel", () => { + it("should generate sensible ids from labels", () => { + const labelIdPairs = [ + ["name", "name"], + ["Name", "name"], + ["FirstName", "firstName"], + ["name of", "nameOf"], + ["test's name", "testsName"], + ["name 123", "name123"], + ["123 name", "123Name"], // this is possible in JavaScript + ["trailing space ", "trailingSpace"], + ["special chars !@#$%^&*()_+{}|:\"<>?`-=[]\\;',./", "specialChars"], + ]; + + for (const testCase of labelIdPairs) { + const generatedId = generateIdFromLabel(testCase[0]); + expect(generatedId).toBe(testCase[1]); + } + }); +}); diff --git a/src/app/utils/generate-id-from-label/generate-id-from-label.ts b/src/app/utils/generate-id-from-label/generate-id-from-label.ts new file mode 100644 index 0000000000..6efbcf2291 --- /dev/null +++ b/src/app/utils/generate-id-from-label/generate-id-from-label.ts @@ -0,0 +1,13 @@ +/** + * Create a simplified id string from the given text. + * This generates a camelCase string, so that it can be used as an id. + * @param label The input string to be transformed + */ +export function generateIdFromLabel(label: string) { + return label + .replace(/[^a-zA-Z0-9\s]/g, "") + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s/g, ""); +} diff --git a/src/app/utils/mocked-testing.module.ts b/src/app/utils/mocked-testing.module.ts index a09f3e8bc2..2c2b3f4941 100644 --- a/src/app/utils/mocked-testing.module.ts +++ b/src/app/utils/mocked-testing.module.ts @@ -31,7 +31,7 @@ import { BehaviorSubject } from "rxjs"; * by passing a different state to the method e.g. `MockedTestingModule.withState(LoginState.LOGGED_OUT)`. * The EntityMapper can be initialized with Entities that are passed as the second argument to the static function. * - * This module provides the services `SessionService` `EntityMapperService` together with other often needed services. + * This module provides the services `SessionService` `EntityMapperService` together with other often needed backup. * * If you need a REAL database (e.g. for indices/views) then use the {@link DatabaseTestingModule} instead. */ diff --git a/src/app/utils/storybook-base.module.ts b/src/app/utils/storybook-base.module.ts index c36e96db9f..49e1cc7420 100644 --- a/src/app/utils/storybook-base.module.ts +++ b/src/app/utils/storybook-base.module.ts @@ -22,6 +22,7 @@ import { import { EntityMapperService } from "../core/entity/entity-mapper/entity-mapper.service"; import { DatabaseIndexingService } from "../core/entity/database-indexing/database-indexing.service"; import { TEST_USER } from "./mock-local-session"; +import { EntityConfigService } from "../core/entity/entity-config.service"; componentRegistry.allowDuplicates(); entityRegistry.allowDuplicates(); @@ -73,10 +74,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(); } } diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 051048cb9d..6a41a2a1e0 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -154,7 +154,7 @@ export function parseJwt(token): { /** * This is a simple shorthand function to create factories for services. - * The use case is, when multiple services extends the same class and one of these services will be provided. + * The use case is, when multiple services extend the same class and one of these services will be provided. * @param service the token for which a service is provided * @param factory factory which returns a subtype of class */ 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;