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;