diff --git a/doc/compodoc_sources/how-to-guides/create-custom-view.md b/doc/compodoc_sources/how-to-guides/create-custom-view.md new file mode 100644 index 0000000000..51ccb5214d --- /dev/null +++ b/doc/compodoc_sources/how-to-guides/create-custom-view.md @@ -0,0 +1,64 @@ +# How to create a custom View Component +We aim to build flexible, reusable components. +If you implement a custom component using the building blocks of Aam Digital's platform, this can seamlessly be displayed both in modal forms and as a fullscreen view. + +## Architecture & Generic wrapper components +The following architecture allows you to implement components that only have `@Input` properties +and do not access either Route or Dialog data directly. +Instead, the platform always uses `RoutedViewComponent` or `DialogViewComponent` to parse such context and pass it into your component as simple Angular @Inputs. + +![](../../images/routed-views.png) + +If you implement a special view to display a single entities' details, you should also extend `AbstractEntityDetailsComponent` with your component. +This takes care of loading the entity from the database, in case it is passed in as an id from the URL. + +## Implementing a custom view Component + +1. Create a new component class +2. Add any `@Input()` properties for values that are provided from the config. +3. For EntityDetails views, you get access to an `@Input() entity` and `@Input() entityConstructor` via the `AbstractEntityDetailsComponent` automatically. Otherwise, you do not have to extend from this. +4. Use `` and `` in your template to wrap the elements (if any) that you want to display as a header and action buttons. +These parts are automatically placed differently in the layout depending on whether your component is display as a fullscreen, routed view (actions displayed top right) or as a dialog/modal (actions displayed fixed at bottom). +5. Register your component under a name (string) with the `ComponentRegistry` (usually we do this in one of the modules), so that it can be referenced under this string form the config. +6. You can then use it in config, as shown below. + +Example template for a custom view component: +```html + + + My Entity {{ entity.name }} + + + +
+ My Custom View Content +
+ + + + + +``` + +An example config for the above: +```json +{ + "component": "MyView", + "config": { "showDescription": true } +} +``` + +Use the `ComponentRegistry` to register your component, +e.g. in its Module: +```javascript +export class MyModule { + constructor(components: ComponentRegistry) { + components.addAll([ + [ + "MyView", // this is the name to use in the config document + () => import("./my-view/my-view.component").then((c) => c.MyViewComponent), + ], + ]); + } +} +``` diff --git a/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md b/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md index c288856047..0868d4ce13 100644 --- a/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md +++ b/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md @@ -21,6 +21,7 @@ Those background details aside, what that means for your implementation is: (e.g. `@Input() showDescription: boolean;`, which you can use in your template or code to adapt the component.) These values are automatically set to whatever value is specified in the config object for your component at runtime in the database. 4. Register the new component in its parent module, so that it can be loaded under its name through the config. + (for details see [Create a custom View Component](./create-a-custom-view-component.html)) An example config for the above: ```json @@ -29,18 +30,3 @@ An example config for the above: "config": { "showDescription": true } } ``` - -Use the `ComponentRegistry` to register your component, -e.g. in its Module: -```javascript -export class MyModule { - constructor(components: ComponentRegistry) { - components.addAll([ - [ - "MySubView", // this is the name to use in the config document - () => import("./my-sub-view/my-sub-view.component").then((c) => c.MySubViewComponent), - ], - ]); - } -} -``` diff --git a/doc/compodoc_sources/summary.json b/doc/compodoc_sources/summary.json index 244192ddaa..52b0cf86d3 100644 --- a/doc/compodoc_sources/summary.json +++ b/doc/compodoc_sources/summary.json @@ -119,6 +119,10 @@ "title": "Create a New Entity Type", "file": "how-to-guides/create-new-entity-type.md" }, + { + "title": "Create a custom View Component", + "file": "how-to-guides/create-custom-view.md" + }, { "title": "Create an Entity Details Panel", "file": "how-to-guides/create-entity-details-panel.md" diff --git a/doc/images/routed-views.png b/doc/images/routed-views.png new file mode 100644 index 0000000000..37beeea660 Binary files /dev/null and b/doc/images/routed-views.png differ diff --git a/src/app/child-dev-project/children/children-list/children-list.component.spec.ts b/src/app/child-dev-project/children/children-list/children-list.component.spec.ts index e080a43b13..ca6d940619 100644 --- a/src/app/child-dev-project/children/children-list/children-list.component.spec.ts +++ b/src/app/child-dev-project/children/children-list/children-list.component.spec.ts @@ -8,7 +8,6 @@ import { BooleanFilterConfig, EntityListConfig, } from "../../../core/entity-list/EntityListConfig"; -import { School } from "../../schools/model/school"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { DownloadService } from "../../../core/export/download-service/download.service"; @@ -101,9 +100,9 @@ describe("ChildrenListComponent", () => { const child1 = new Child("c1"); const child2 = new Child("c2"); mockChildrenService.getChildren.and.resolveTo([child1, child2]); - await component.ngOnInit(); + await component.ngOnChanges({}); expect(mockChildrenService.getChildren).toHaveBeenCalled(); - expect(component.childrenList).toEqual([child1, child2]); + expect(component.allEntities).toEqual([child1, child2]); }); }); 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 bd1c7ed4ff..e61c947d10 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 @@ -1,42 +1,107 @@ -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { Child } from "../model/child"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { ChildrenService } from "../children.service"; -import { EntityListConfig } from "../../../core/entity-list/EntityListConfig"; -import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { EntityListComponent } from "../../../core/entity-list/entity-list/entity-list.component"; import { RouteTarget } from "../../../route-target"; +import { ScreenWidthObserver } from "../../../utils/media/screen-size-observer.service"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { EntityRegistry } from "../../../core/entity/database-entity.decorator"; +import { MatDialog } from "@angular/material/dialog"; +import { DuplicateRecordService } from "../../../core/entity-list/duplicate-records/duplicate-records.service"; +import { EntityActionsService } from "../../../core/entity/entity-actions/entity-actions.service"; +import { + AsyncPipe, + NgForOf, + NgIf, + NgStyle, + NgTemplateOutlet, +} from "@angular/common"; +import { MatButtonModule } from "@angular/material/button"; +import { Angulartics2OnModule } from "angulartics2"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { MatMenuModule } from "@angular/material/menu"; +import { MatTabsModule } from "@angular/material/tabs"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component"; +import { FormsModule } from "@angular/forms"; +import { FilterComponent } from "../../../core/filter/filter/filter.component"; +import { TabStateModule } from "../../../utils/tab-state/tab-state.module"; +import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { ExportDataDirective } from "../../../core/export/export-data-directive/export-data.directive"; +import { DisableEntityOperationDirective } from "../../../core/permissions/permission-directive/disable-entity-operation.directive"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { EntityCreateButtonComponent } from "../../../core/common-components/entity-create-button/entity-create-button.component"; +import { AbilityModule } from "@casl/angular"; +import { EntityActionsMenuComponent } from "../../../core/entity-details/entity-actions-menu/entity-actions-menu.component"; +import { ViewActionsComponent } from "../../../core/common-components/view-actions/view-actions.component"; @RouteTarget("ChildrenList") @Component({ selector: "app-children-list", - template: ` - - `, + templateUrl: + "../../../core/entity-list/entity-list/entity-list.component.html", + styleUrls: [ + "../../../core/entity-list/entity-list/entity-list.component.scss", + ], standalone: true, - imports: [EntityListComponent], + + imports: [ + NgIf, + NgStyle, + MatButtonModule, + Angulartics2OnModule, + FontAwesomeModule, + MatMenuModule, + NgTemplateOutlet, + MatTabsModule, + NgForOf, + MatFormFieldModule, + MatInputModule, + EntitiesTableComponent, + FormsModule, + FilterComponent, + TabStateModule, + ViewTitleComponent, + ExportDataDirective, + DisableEntityOperationDirective, + RouterLink, + MatTooltipModule, + EntityCreateButtonComponent, + AbilityModule, + AsyncPipe, + EntityActionsMenuComponent, + ViewActionsComponent, + ], }) -export class ChildrenListComponent implements OnInit { - childrenList: Child[]; - listConfig: EntityListConfig; - childConstructor = Child; +export class ChildrenListComponent extends EntityListComponent { + override entityConstructor = Child; constructor( + screenWidthObserver: ScreenWidthObserver, + router: Router, + activatedRoute: ActivatedRoute, + entityMapperService: EntityMapperService, + entities: EntityRegistry, + dialog: MatDialog, + duplicateRecord: DuplicateRecordService, + entityActionsService: EntityActionsService, private childrenService: ChildrenService, - private route: ActivatedRoute, - ) {} - - async ngOnInit() { - this.route.data.subscribe( - // TODO replace this use of route and rely on the RoutedViewComponent instead - // see that flattens the config option, assigning individual properties as inputs however, so we can't easily pass on - (data: DynamicComponentConfig) => - (this.listConfig = data.config), + ) { + super( + screenWidthObserver, + router, + activatedRoute, + entityMapperService, + entities, + dialog, + duplicateRecord, + entityActionsService, ); - this.childrenList = await this.childrenService.getChildren(); + } + + override async getEntities() { + return this.childrenService.getChildren(); } } diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.html b/src/app/child-dev-project/notes/note-details/note-details.component.html index ce73122f35..9256917b1a 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.html +++ b/src/app/child-dev-project/notes/note-details/note-details.component.html @@ -1,24 +1,12 @@ - - -

{{ tmpEntity.date | date }}: {{ tmpEntity.subject }}

- +@if (isLoading || !tmpEntity) { +
+ +
+} @else { + + {{ tmpEntity.date | date }}: {{ tmpEntity.subject }} + -
@@ -57,31 +45,31 @@

{{ tmpEntity.date | date }}: {{ tmpEntity.subject }}

style="margin-top: 10px" >
-
- - - - - + + + + + +} diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.ts b/src/app/child-dev-project/notes/note-details/note-details.component.ts index 6dfa813e7b..878cf0218e 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.ts +++ b/src/app/child-dev-project/notes/note-details/note-details.component.ts @@ -1,9 +1,8 @@ import { Component, - Inject, Input, - OnInit, - Optional, + OnChanges, + SimpleChanges, ViewEncapsulation, } from "@angular/core"; import { Note } from "../model/note"; @@ -22,17 +21,29 @@ import { } from "../../../core/common-components/entity-form/entity-form.service"; import { EntityFormComponent } from "../../../core/common-components/entity-form/entity-form/entity-form.component"; import { DynamicComponentDirective } from "../../../core/config/dynamic-components/dynamic-component.directive"; -import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog"; +import { MatDialogModule } from "@angular/material/dialog"; import { DialogButtonsComponent } from "../../../core/form-dialog/dialog-buttons/dialog-buttons.component"; import { DialogCloseComponent } from "../../../core/common-components/dialog-close/dialog-close.component"; import { EntityArchivedInfoComponent } from "../../../core/entity-details/entity-archived-info/entity-archived-info.component"; import { EntityFieldEditComponent } from "../../../core/common-components/entity-field-edit/entity-field-edit.component"; import { FieldGroup } from "../../../core/entity-details/form/field-group"; +import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator"; +import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { AbstractEntityDetailsComponent } from "../../../core/entity-details/abstract-entity-details/abstract-entity-details.component"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { EntityRegistry } from "../../../core/entity/database-entity.decorator"; +import { EntityAbility } from "../../../core/permissions/ability/entity-ability"; +import { Router } from "@angular/router"; +import { LoggingService } from "../../../core/logging/logging.service"; +import { UnsavedChangesService } from "../../../core/entity-details/form/unsaved-changes.service"; +import { MatProgressBar } from "@angular/material/progress-bar"; +import { ViewActionsComponent } from "../../../core/common-components/view-actions/view-actions.component"; /** * Component responsible for displaying the Note creation/view window */ @UntilDestroy() +@DynamicComponent("NoteDetails") @Component({ selector: "app-note-details", templateUrl: "./note-details.component.html", @@ -50,19 +61,33 @@ import { FieldGroup } from "../../../core/entity-details/form/field-group"; DialogCloseComponent, EntityArchivedInfoComponent, EntityFieldEditComponent, + ViewTitleComponent, + MatProgressBar, + ViewActionsComponent, ], standalone: true, encapsulation: ViewEncapsulation.None, }) -export class NoteDetailsComponent implements OnInit { +export class NoteDetailsComponent + extends AbstractEntityDetailsComponent + implements OnChanges +{ @Input() entity: Note; + entityConstructor = Note; /** export format for notes to be used for downloading the individual details */ exportConfig: ExportColumnConfig[]; - topForm = ["date", "warningLevel", "category", "authors", "attachment"]; - middleForm = ["subject", "text"]; - bottomForm = ["children", "schools"]; + @Input() topForm = [ + "date", + "warningLevel", + "category", + "authors", + "attachment", + ]; + @Input() middleForm = ["subject", "text"]; + @Input() bottomForm = ["children", "schools"]; + topFieldGroups: FieldGroup[]; bottomFieldGroups: FieldGroup[]; @@ -70,26 +95,32 @@ export class NoteDetailsComponent implements OnInit { tmpEntity: Note; constructor( + entityMapperService: EntityMapperService, + entities: EntityRegistry, + ability: EntityAbility, + router: Router, + logger: LoggingService, + unsavedChanges: UnsavedChangesService, private configService: ConfigService, private entityFormService: EntityFormService, - @Optional() @Inject(MAT_DIALOG_DATA) data: { entity: Note }, ) { - if (data) { - this.entity = data.entity; - } + super( + entityMapperService, + entities, + ability, + router, + logger, + unsavedChanges, + ); + this.exportConfig = this.configService.getConfig<{ config: EntityListConfig; }>("view:note")?.config.exportConfig; - - const formConfig = this.configService.getConfig( - "appConfig:note-details", - ); - this.topForm = formConfig?.topForm ?? this.topForm; - this.middleForm = formConfig?.middleForm ?? this.middleForm; - this.bottomForm = formConfig?.bottomForm ?? this.bottomForm; } - ngOnInit() { + async ngOnChanges(changes: SimpleChanges) { + await super.ngOnChanges(changes); + this.topFieldGroups = this.topForm.map((f) => ({ fields: [f] })); this.bottomFieldGroups = [{ fields: this.bottomForm }]; diff --git a/src/app/child-dev-project/notes/notes-components.ts b/src/app/child-dev-project/notes/notes-components.ts index 5afc78891d..7d4af46b86 100644 --- a/src/app/child-dev-project/notes/notes-components.ts +++ b/src/app/child-dev-project/notes/notes-components.ts @@ -51,4 +51,11 @@ export const notesComponents: ComponentTuple[] = [ "./dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component" ).then((c) => c.ImportantNotesDashboardComponent), ], + [ + "NoteDetails", + () => + import("./note-details/note-details.component").then( + (c) => c.NoteDetailsComponent, + ), + ], ]; diff --git a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html index cad8702d47..8ee604e28d 100644 --- a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html +++ b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html @@ -1,10 +1,16 @@ + +
+ -

- -

+

+ +

+
+
+ +@if (!viewContext) { + +} 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 f03d963386..a0e96c01ad 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 @@ -1,8 +1,9 @@ -:host { - display: flex; - flex-direction: row; +.container { align-items: center; margin-bottom: 0 !important; +} - max-width: 100%; +.back-button { + position: relative; + left: -12px; } diff --git a/src/app/core/common-components/view-title/view-title.component.ts b/src/app/core/common-components/view-title/view-title.component.ts index 2e53ddd4bb..d49a3fd0a5 100644 --- a/src/app/core/common-components/view-title/view-title.component.ts +++ b/src/app/core/common-components/view-title/view-title.component.ts @@ -1,25 +1,40 @@ import { + AfterViewInit, Component, HostBinding, Input, - OnChanges, - SimpleChanges, + Optional, + TemplateRef, + ViewChild, } from "@angular/core"; import { getUrlWithoutParams } from "../../../utils/utils"; import { Router } from "@angular/router"; -import { Location, NgIf } from "@angular/common"; +import { Location, NgIf, NgTemplateOutlet } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; import { MatTooltipModule } from "@angular/material/tooltip"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; +/** + * Building block for views, providing a consistent layout to a title section + * for both dialog and routed views. + */ @Component({ selector: "app-view-title", templateUrl: "./view-title.component.html", styleUrls: ["./view-title.component.scss"], - imports: [NgIf, MatButtonModule, MatTooltipModule, FontAwesomeModule], + imports: [ + NgIf, + MatButtonModule, + MatTooltipModule, + FontAwesomeModule, + NgTemplateOutlet, + ], standalone: true, }) -export class ViewTitleComponent implements OnChanges { +export class ViewTitleComponent implements AfterViewInit { + @ViewChild("template") template: TemplateRef; + /** The page title to be displayed */ @Input() title: string; @@ -36,8 +51,19 @@ export class ViewTitleComponent implements OnChanges { constructor( private router: Router, private location: Location, + @Optional() protected viewContext: ViewComponentContext, ) { this.parentUrl = this.findParentUrl(); + + if (this.viewContext?.isDialog) { + this.disableBackButton = true; + } + } + + ngAfterViewInit(): void { + if (this.viewContext) { + setTimeout(() => (this.viewContext.title = this)); + } } private findParentUrl(): string { @@ -59,23 +85,5 @@ export class ViewTitleComponent implements OnChanges { } } - ngOnChanges(changes: SimpleChanges) { - if (changes.hasOwnProperty("disableBackButton")) { - this.extraStyles = this.buildExtraStyles(); - } - } - - private buildExtraStyles() { - /* Moves the whole title component 12 pixels to the left so that - * the "go back" button is aligned with the left border. This class - * is applied conditionally when the "back" button is shown - */ - return { - position: "relative", - left: this.disableBackButton ? "unset" : "-12px", - }; - } - @HostBinding("class") extraClasses = "mat-title"; - @HostBinding("style") extraStyles = this.buildExtraStyles(); } diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index e3a1b19b84..99f72c9c75 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -161,7 +161,6 @@ export const defaultJsonConfig = { "view:note": { "component": "NotesManager", "config": { - "entityType": "Note", "title": $localize`:Title for notes overview:Notes & Reports`, "includeEventNotes": false, "showEventNotesToggle": true, @@ -237,6 +236,12 @@ export const defaultJsonConfig = { ] } }, + "view:note/:id": { + "component": "NoteDetails", + "config": { + "topForm": ["date", "warningLevel", "category", "authors", "attachment"] + } + }, "view:import": { "component": "Import", }, @@ -714,6 +719,11 @@ export const defaultJsonConfig = { "title", "type", "assignedTo" + ], + "exportConfig": [ + { "label": "Title", "query": "title" }, + { "label": "Type", "query": "type" }, + { "label": "Assigned users", "query": "assignedTo" } ] } }, diff --git a/src/app/core/config/dynamic-components/dynamic-component.pipe.ts b/src/app/core/config/dynamic-components/dynamic-component.pipe.ts new file mode 100644 index 0000000000..ab582e35e5 --- /dev/null +++ b/src/app/core/config/dynamic-components/dynamic-component.pipe.ts @@ -0,0 +1,27 @@ +import { Pipe, PipeTransform, Type } from "@angular/core"; +import { ComponentRegistry } from "../../../dynamic-components"; + +/** + * Transform a string "component name" and load the referenced component. + * + * This is async and needs an additional async pipe. Use with *ngComponentOutlet +``` + +``` + */ +@Pipe({ + name: "dynamicComponent", + standalone: true, +}) +export class DynamicComponentPipe implements PipeTransform { + constructor(private componentRegistry: ComponentRegistry) {} + + async transform(value: string): Promise> { + return await this.componentRegistry.get(value)(); + } +} diff --git a/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts new file mode 100644 index 0000000000..ed402ad710 --- /dev/null +++ b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts @@ -0,0 +1,129 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from "@angular/core/testing"; +import { AbstractEntityDetailsComponent } from "./abstract-entity-details.component"; +import { Router } from "@angular/router"; +import { EntityDetailsConfig } from "../EntityDetailsConfig"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { EntityActionsService } from "../../entity/entity-actions/entity-actions.service"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { Component, SimpleChange } from "@angular/core"; +import { mockEntityMapper } from "../../entity/entity-mapper/mock-entity-mapper-service"; + +@Component({ + template: ``, + standalone: true, +}) +class TestEntityDetailsComponent extends AbstractEntityDetailsComponent {} + +describe("AbstractEntityDetailsComponent", () => { + let component: TestEntityDetailsComponent; + let fixture: ComponentFixture; + + const routeConfig: EntityDetailsConfig = { + entityType: "Child", + panels: [], + }; + + let mockEntityRemoveService: jasmine.SpyObj; + let mockAbility: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + mockEntityRemoveService = jasmine.createSpyObj(["remove"]); + mockAbility = jasmine.createSpyObj(["cannot", "update", "on"]); + mockAbility.cannot.and.returnValue(false); + mockAbility.on.and.returnValue(() => true); + + TestBed.configureTestingModule({ + imports: [TestEntityDetailsComponent, MockedTestingModule.withState()], + providers: [ + { provide: EntityMapperService, useValue: mockEntityMapper() }, + { provide: EntityActionsService, useValue: mockEntityRemoveService }, + { provide: EntityAbility, useValue: mockAbility }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEntityDetailsComponent); + component = fixture.componentInstance; + + Object.assign(component, routeConfig); + component.ngOnChanges( + simpleChangesFor(component, ...Object.keys(routeConfig)), + ); + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should load the correct entity on init", fakeAsync(() => { + component.isLoading = true; + const testChild = new Child("Test-Child"); + const entityMapper = TestBed.inject(EntityMapperService); + entityMapper.save(testChild); + tick(); + spyOn(entityMapper, "load").and.callThrough(); + + component.id = testChild.getId(true); + component.ngOnChanges(simpleChangesFor(component, "id")); + expect(component.isLoading).toBeTrue(); + tick(); + + expect(entityMapper.load).toHaveBeenCalledWith( + Child, + testChild.getId(true), + ); + expect(component.entity).toBe(testChild); + expect(component.isLoading).toBeFalse(); + })); + + it("should also support the long ID format", fakeAsync(() => { + const child = new Child(); + const entityMapper = TestBed.inject(EntityMapperService); + entityMapper.save(child); + tick(); + spyOn(entityMapper, "load").and.callThrough(); + + component.id = child.getId(); + component.ngOnChanges(simpleChangesFor(component, "id")); + tick(); + + expect(entityMapper.load).toHaveBeenCalledWith(Child, child.getId()); + expect(component.entity).toEqual(child); + + // entity is updated + const childUpdate = child.copy(); + childUpdate.name = "update"; + entityMapper.save(childUpdate); + tick(); + + expect(component.entity).toEqual(childUpdate); + })); + + it("should call router when user is not permitted to create entities", () => { + mockAbility.cannot.and.returnValue(true); + const router = fixture.debugElement.injector.get(Router); + spyOn(router, "navigate"); + component.id = "new"; + component.ngOnChanges(simpleChangesFor(component, "id")); + expect(router.navigate).toHaveBeenCalled(); + }); +}); + +function simpleChangesFor(component, ...properties: string[]) { + const changes = {}; + for (const p of properties) { + changes[p] = new SimpleChange(null, component[p], true); + } + return changes; +} diff --git a/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts new file mode 100644 index 0000000000..97edf9f480 --- /dev/null +++ b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts @@ -0,0 +1,79 @@ +import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { Router } from "@angular/router"; +import { Entity, EntityConstructor } from "../../entity/model/entity"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { EntityRegistry } from "../../entity/database-entity.decorator"; +import { filter } from "rxjs/operators"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { Subscription } from "rxjs"; +import { LoggingService } from "../../logging/logging.service"; +import { UnsavedChangesService } from "../form/unsaved-changes.service"; + +/** + * This component can be used to display an entity in more detail. + * As an abstract base component, this provides functionality to load an entity + * and leaves the UI and special functionality to components that extend this class, like EntityDetailsComponent. + */ +@UntilDestroy() +@Directive() +export abstract class AbstractEntityDetailsComponent implements OnChanges { + isLoading: boolean; + private changesSubscription: Subscription; + + @Input() entityType: string; + entityConstructor: EntityConstructor; + + @Input() id: string; + @Input() entity: Entity; + + constructor( + private entityMapperService: EntityMapperService, + private entities: EntityRegistry, + private ability: EntityAbility, + private router: Router, + protected logger: LoggingService, + protected unsavedChanges: UnsavedChangesService, + ) {} + + async ngOnChanges(changes: SimpleChanges) { + if (changes.entityType) { + this.entityConstructor = this.entities.get(this.entityType); + } + + if (changes.id) { + await this.loadEntity(); + this.subscribeToEntityChanges(); + } + } + + protected subscribeToEntityChanges() { + const fullId = Entity.createPrefixedId(this.entityType, this.id); + this.changesSubscription?.unsubscribe(); + this.changesSubscription = this.entityMapperService + .receiveUpdates(this.entityConstructor) + .pipe( + filter(({ entity }) => entity.getId() === fullId), + filter(({ type }) => type !== "remove"), + untilDestroyed(this), + ) + .subscribe(({ entity }) => (this.entity = entity)); + } + + protected async loadEntity() { + this.isLoading = true; + if (this.id === "new") { + if (this.ability.cannot("create", this.entityConstructor)) { + this.router.navigate([""]); + return; + } + this.entity = new this.entityConstructor(); + } else { + this.entity = await this.entityMapperService.load( + this.entityConstructor, + this.id, + ); + } + this.isLoading = false; + } +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html index dc0ba188be..9526faa07b 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html @@ -1,39 +1,66 @@ - +@if (!entity?.isNew) { + + @for (a of actions; track a.action) { + @if (showExpanded && viewContext?.isDialog && a.primaryAction) { + + } + } - - - + + - - - + + + + @for (a of actions; track a.action) { + @if (!a.primaryAction || !showExpanded || !viewContext?.isDialog) { + + } + } - - - + + + +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss index e69de29bb2..4b374974e7 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + align-items: center; +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts index 625821d4af..923fc6db27 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts @@ -3,6 +3,7 @@ import { EventEmitter, Input, OnChanges, + Optional, Output, SimpleChanges, } from "@angular/core"; @@ -17,6 +18,7 @@ import { DisableEntityOperationDirective } from "../../permissions/permission-di import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { EntityAction } from "../../permissions/permission-types"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; export type EntityMenuAction = "archive" | "anonymize" | "delete"; type EntityMenuActionItem = { @@ -26,6 +28,9 @@ type EntityMenuActionItem = { icon: IconProp; label: string; tooltip?: string; + + /** important action to be displayed directly, outside context menu in some views */ + primaryAction?: boolean; }; @Component({ @@ -68,6 +73,7 @@ export class EntityActionsMenuComponent implements OnChanges { icon: "box-archive", label: $localize`:entity context menu:Archive`, tooltip: $localize`:entity context menu tooltip:Mark the record as inactive, hiding it from lists by default while keeping the data.`, + primaryAction: true, }, { action: "anonymize", @@ -87,7 +93,15 @@ export class EntityActionsMenuComponent implements OnChanges { }, ]; - constructor(private entityRemoveService: EntityActionsService) {} + /** + * Whether some buttons should be displayed directly, outside the three-dot menu in dialog views. + */ + @Input() showExpanded?: boolean; + + constructor( + private entityRemoveService: EntityActionsService, + @Optional() protected viewContext: ViewComponentContext, + ) {} ngOnChanges(changes: SimpleChanges): void { if (changes.entity) { @@ -111,9 +125,13 @@ export class EntityActionsMenuComponent implements OnChanges { } async executeAction(action: EntityMenuActionItem) { - const result = await action.execute(this.entity, this.navigateOnDelete); + const result = await action.execute( + this.entity, + this.navigateOnDelete && !this.viewContext?.isDialog, + ); if (result) { this.actionTriggered.emit(action.action); } + setTimeout(() => this.filterAvailableActions()); } } 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 bcbc5eded0..fb46b34003 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 @@ -1,37 +1,23 @@ - + + + @if (!entity?.isNew) { + {{ entity?.toString() }} + } @else { + + Adding new {{ this.entityConstructor?.label }} + + } + -
- - {{ record?.toString() }} - - + - Adding new {{ this.entityConstructor?.label }} - - - -
+
- + - + + { it("sets the panels config with child and creating status", fakeAsync(() => { const testChild = new Child("Test-Child"); + testChild["_rev"] = "1"; // mark as "not new" TestBed.inject(EntityMapperService).save(testChild); tick(); - component.creatingNew = false; component.id = testChild.getId(true); component.ngOnChanges(simpleChangesFor(component, "id")); tick(); @@ -98,59 +97,6 @@ describe("EntityDetailsComponent", () => { }), ); })); - - it("should load the correct child on startup", fakeAsync(() => { - component.isLoading = true; - const testChild = new Child("Test-Child"); - const entityMapper = TestBed.inject(EntityMapperService); - entityMapper.save(testChild); - tick(); - spyOn(entityMapper, "load").and.callThrough(); - - component.id = testChild.getId(true); - component.ngOnChanges(simpleChangesFor(component, "id")); - expect(component.isLoading).toBeTrue(); - tick(); - - expect(entityMapper.load).toHaveBeenCalledWith( - Child, - testChild.getId(true), - ); - expect(component.record).toBe(testChild); - expect(component.isLoading).toBeFalse(); - })); - - it("should also support the long ID format", fakeAsync(() => { - const child = new Child(); - const entityMapper = TestBed.inject(EntityMapperService); - entityMapper.save(child); - tick(); - spyOn(entityMapper, "load").and.callThrough(); - - component.id = child.getId(); - component.ngOnChanges(simpleChangesFor(component, "id")); - tick(); - - expect(entityMapper.load).toHaveBeenCalledWith(Child, child.getId()); - expect(component.record).toEqual(child); - - // entity is updated - const childUpdate = child.copy(); - childUpdate.name = "update"; - entityMapper.save(childUpdate); - tick(); - - expect(component.record).toEqual(childUpdate); - })); - - it("should call router when user is not permitted to create entities", () => { - mockAbility.cannot.and.returnValue(true); - const router = fixture.debugElement.injector.get(Router); - spyOn(router, "navigate"); - component.id = "new"; - component.ngOnChanges(simpleChangesFor(component, "id")); - expect(router.navigate).toHaveBeenCalled(); - }); }); function simpleChangesFor(component, ...properties: string[]) { 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 4e0e6962e0..00124a88ea 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,6 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { Router, RouterLink } from "@angular/router"; +import { 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 { EntityRegistry } from "../../entity/database-entity.decorator"; import { MatButtonModule } from "@angular/material/button"; import { MatMenuModule } from "@angular/material/menu"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; @@ -18,15 +13,13 @@ 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"; -import { LoggingService } from "../../logging/logging.service"; -import { UnsavedChangesService } from "../form/unsaved-changes.service"; import { EntityActionsMenuComponent } from "../entity-actions-menu/entity-actions-menu.component"; import { EntityArchivedInfoComponent } from "../entity-archived-info/entity-archived-info.component"; -import { filter } from "rxjs/operators"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { Subscription } from "rxjs"; +import { UntilDestroy } from "@ngneat/until-destroy"; import { AbilityModule } from "@casl/angular"; import { RouteTarget } from "../../../route-target"; +import { AbstractEntityDetailsComponent } from "../abstract-entity-details/abstract-entity-details.component"; +import { ViewActionsComponent } from "../../common-components/view-actions/view-actions.component"; /** * This component can be used to display an entity in more detail. @@ -60,80 +53,26 @@ import { RouteTarget } from "../../../route-target"; RouterLink, AbilityModule, CommonModule, + ViewActionsComponent, ], }) -export class EntityDetailsComponent implements OnChanges { - creatingNew = false; - isLoading = true; - private changesSubscription: Subscription; - - @Input() entityType: string; - entityConstructor: EntityConstructor; - - // TODO: instead use an @Input entity: Entity and let RoutedView handle the entity loading - @Input() id: string; - record: Entity; - +export class EntityDetailsComponent + extends AbstractEntityDetailsComponent + implements OnChanges +{ /** * The configuration for the panels on this details page. */ @Input() panels: Panel[] = []; - constructor( - private entityMapperService: EntityMapperService, - private router: Router, - private analyticsService: AnalyticsService, - private ability: EntityAbility, - private entities: EntityRegistry, - private logger: LoggingService, - public unsavedChanges: UnsavedChangesService, - ) {} + async ngOnChanges(changes: SimpleChanges) { + await super.ngOnChanges(changes); - ngOnChanges(changes: SimpleChanges): void { - if (changes.entity || changes.entityType) { - this.entityConstructor = this.entities.get(this.entityType); - } - if (changes.id) { - this.loadEntity(); - this.subscribeToEntityChanges(); - // `initPanels()` is already called inside `loadEntity()` - } else if (changes.panels) { + if (changes.id || changes.entity || changes.panels) { this.initPanels(); } } - private subscribeToEntityChanges() { - const fullId = Entity.createPrefixedId(this.entityType, this.id); - this.changesSubscription?.unsubscribe(); - this.changesSubscription = this.entityMapperService - .receiveUpdates(this.entityConstructor) - .pipe( - filter(({ entity }) => entity.getId() === fullId), - filter(({ type }) => type !== "remove"), - untilDestroyed(this), - ) - .subscribe(({ entity }) => (this.record = entity)); - } - - private async loadEntity() { - if (this.id === "new") { - if (this.ability.cannot("create", this.entityConstructor)) { - this.router.navigate([""]); - return; - } - this.record = new this.entityConstructor(); - this.creatingNew = true; - } else { - this.creatingNew = false; - this.record = await this.entityMapperService.load( - this.entityConstructor, - this.id, - ); - } - this.initPanels(); - this.isLoading = false; - } - private initPanels() { this.panels = this.panels.map((p) => ({ title: p.title, @@ -147,30 +86,14 @@ export class EntityDetailsComponent implements OnChanges { private getPanelConfig(c: PanelComponent): PanelConfig { let panelConfig: PanelConfig = { - entity: this.record, - creatingNew: this.creatingNew, + entity: this.entity, + creatingNew: this.entity.isNew, }; if (typeof c.config === "object" && !Array.isArray(c.config)) { - if (c.config?.entity) { - this.logger.warn( - `DEPRECATION panel config uses 'entity' keyword: ${JSON.stringify( - c, - )}`, - ); - c.config["entityType"] = c.config.entity; - delete c.config.entity; - } panelConfig = { ...c.config, ...panelConfig }; } else { panelConfig.config = c.config; } return panelConfig; } - - trackTabChanged(index: number) { - this.analyticsService.eventTrack("details_tab_changed", { - category: this.entityType, - label: this.panels[index].title, - }); - } } diff --git a/src/app/core/entity-details/form/form.component.ts b/src/app/core/entity-details/form/form.component.ts index 76aaefcbb3..5aa979621a 100644 --- a/src/app/core/entity-details/form/form.component.ts +++ b/src/app/core/entity-details/form/form.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, OnInit, Optional } from "@angular/core"; import { Entity } from "../../entity/model/entity"; import { getParentUrl } from "../../../utils/utils"; import { Router } from "@angular/router"; @@ -14,6 +14,7 @@ import { MatButtonModule } from "@angular/material/button"; import { EntityFormComponent } from "../../common-components/entity-form/entity-form/entity-form.component"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; import { FieldGroup } from "./field-group"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; /** * A simple wrapper function of the EntityFormComponent which can be used as a dynamic component @@ -45,6 +46,7 @@ export class FormComponent implements FormConfig, OnInit { private location: Location, private entityFormService: EntityFormService, private alertService: AlertService, + @Optional() private viewContext: ViewComponentContext, ) {} ngOnInit() { @@ -63,7 +65,7 @@ export class FormComponent implements FormConfig, OnInit { await this.entityFormService.saveChanges(this.form, this.entity); this.form.markAsPristine(); this.form.disable(); - if (this.creatingNew) { + if (this.creatingNew && !this.viewContext?.isDialog) { await this.router.navigate([ getParentUrl(this.router), this.entity.getId(true), diff --git a/src/app/core/entity-list/entity-list/entity-list.component.html b/src/app/core/entity-list/entity-list/entity-list.component.html index b5895146ea..8f40cd2cbd 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.html +++ b/src/app/core/entity-list/entity-list/entity-list.component.html @@ -1,12 +1,11 @@
+ + {{ title }} + -
- - {{ title }} - - +
@@ -19,7 +18,7 @@
-
+ diff --git a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts index 76117a9346..25dbe6cd7a 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts @@ -154,18 +154,15 @@ describe("EntityListComponent", () => { } component.entityConstructor = Test; - component.listConfig = { - title: "", - columns: [ - { - id: "anotherColumn", - label: "Predefined Title", - viewComponent: "DisplayDate", - }, - ], - columnGroups: { - groups: [{ name: "Both", columns: ["testProperty", "anotherColumn"] }], + component.columns = [ + { + id: "anotherColumn", + label: "Predefined Title", + viewComponent: "DisplayDate", }, + ]; + component.columnGroups = { + groups: [{ name: "Both", columns: ["testProperty", "anotherColumn"] }], }; component.ngOnChanges({ listConfig: null }); @@ -177,30 +174,6 @@ describe("EntityListComponent", () => { ]); })); - it("should automatically initialize values if directly referenced from config", fakeAsync(() => { - mockActivatedRoute.component = EntityListComponent; - const entityMapper = TestBed.inject(EntityMapperService); - const children = [new Child(), new Child()]; - spyOn(entityMapper, "loadType").and.resolveTo(children); - - createComponent(); - component.listConfig = { - entityType: "Child", - title: "Some title", - columns: ["name", "gender"], - }; - component.ngOnChanges({ listConfig: undefined }); - tick(); - - expect(component.entityConstructor).toBe(Child); - expect(component.allEntities).toEqual(children); - expect(component.title).toBe("Some title"); - - const navigateSpy = spyOn(TestBed.inject(Router), "navigate"); - component.addNew(); - expect(navigateSpy.calls.mostRecent().args[0]).toEqual(["new"]); - })); - it("should not navigate on addNew if clickMode is not 'navigate'", () => { createComponent(); const navigateSpy = spyOn(TestBed.inject(Router), "navigate"); @@ -257,10 +230,9 @@ describe("EntityListComponent", () => { } async function initComponentInputs() { - component.listConfig = testConfig; + Object.assign(component, testConfig); await component.ngOnChanges({ allEntities: undefined, - listConfig: undefined, }); fixture.detectChanges(); } 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 518a638996..8d9231f7f7 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 @@ -15,7 +15,6 @@ import { } from "../EntityListConfig"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { FormFieldConfig } from "../../common-components/entity-form/FormConfig"; -import { AnalyticsService } from "../../analytics/analytics.service"; 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"; @@ -55,6 +54,7 @@ import { DataFilter } from "../../filter/filters/filters"; import { EntityCreateButtonComponent } from "../../common-components/entity-create-button/entity-create-button.component"; import { AbilityModule } from "@casl/angular"; import { EntityActionsMenuComponent } from "../../entity-details/entity-actions-menu/entity-actions-menu.component"; +import { ViewActionsComponent } from "../../common-components/view-actions/view-actions.component"; /** * This component allows to create a full-blown table with pagination, filtering, searching and grouping. @@ -96,6 +96,8 @@ import { EntityActionsMenuComponent } from "../../entity-details/entity-actions- AbilityModule, AsyncPipe, EntityActionsMenuComponent, + ViewActionsComponent, + // WARNING: all imports here also need to be set for components extending EntityList, like ChildrenListComponent ], standalone: true, }) @@ -105,9 +107,6 @@ export class EntityListComponent { @Input() allEntities: T[]; - /** @deprecated this is often used when this has a wrapper component (e.g. ChildrenList), preferably use individual @Input properties */ - @Input() listConfig: EntityListConfig; - @Input() entityType: string; @Input() entityConstructor: EntityConstructor; @Input() defaultSort: Sort; @@ -167,8 +166,7 @@ export class EntityListComponent private screenWidthObserver: ScreenWidthObserver, private router: Router, private activatedRoute: ActivatedRoute, - private analyticsService: AnalyticsService, - private entityMapperService: EntityMapperService, + protected entityMapperService: EntityMapperService, private entities: EntityRegistry, private dialog: MatDialog, private duplicateRecord: DuplicateRecordService, @@ -192,9 +190,6 @@ export class EntityListComponent } ngOnChanges(changes: SimpleChanges) { - if (changes.hasOwnProperty("listConfig")) { - Object.assign(this, this.listConfig); - } return this.buildComponentFromConfig(); } @@ -221,13 +216,19 @@ export class EntityListComponent ); } - private async loadEntities() { - this.allEntities = await this.entityMapperService.loadType( - this.entityConstructor, - ); + protected async loadEntities() { + this.allEntities = await this.getEntities(); this.listenToEntityUpdates(); } + /** + * Template method that can be overwritten to change the loading logic. + * @protected + */ + protected async getEntities(): Promise { + return this.entityMapperService.loadType(this.entityConstructor); + } + private updateSubscription: Subscription; private listenToEntityUpdates() { @@ -262,10 +263,6 @@ export class EntityListComponent applyFilter(filterValue: string) { // TODO: turn this into one of our filter types, so that all filtering happens the same way (and we avoid accessing internal datasource of sub-component here) this.filterFreetext = filterValue.trim().toLowerCase(); - - this.analyticsService.eventTrack("list_filter_freetext", { - category: this.entityConstructor?.ENTITY_TYPE, - }); } private displayColumnGroupByName(columnGroupName: string) { diff --git a/src/app/core/entity/entity-config.service.ts b/src/app/core/entity/entity-config.service.ts index a17e7cbc71..a158aaa348 100644 --- a/src/app/core/entity/entity-config.service.ts +++ b/src/app/core/entity/entity-config.service.ts @@ -5,9 +5,14 @@ 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"; +import { + PREFIX_VIEW_CONFIG, + ViewConfig, +} from "../config/dynamic-routing/view-config.interface"; import { EntitySchemaField } from "./schema/entity-schema-field"; import { EntitySchema } from "./schema/entity-schema"; +import { EntityDetailsConfig } from "../entity-details/EntityDetailsConfig"; +import { EntityListConfig } from "../entity-list/EntityListConfig"; /** * A service that allows to work with configuration-objects @@ -149,4 +154,19 @@ export class EntityConfigService { EntityConfigService.PREFIX_ENTITY_CONFIG + entityType.ENTITY_TYPE; return this.configService.getConfig(configName); } + + getDetailsViewConfig( + entityType: EntityConstructor, + ): ViewConfig { + return this.configService.getConfig>( + EntityConfigService.getDetailsViewId(entityType), + ); + } + getListViewConfig( + entityType: EntityConstructor, + ): ViewConfig { + return this.configService.getConfig>( + EntityConfigService.getListViewId(entityType), + ); + } } diff --git a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html index 19d8cabcae..61f3fa4c88 100644 --- a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html +++ b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html @@ -12,8 +12,9 @@