diff --git a/src/app/core/core-components.ts b/src/app/core/core-components.ts index 25b48d3dc0..15f45c8211 100644 --- a/src/app/core/core-components.ts +++ b/src/app/core/core-components.ts @@ -201,7 +201,6 @@ export const coreComponents: ComponentTuple[] = [ (c) => c.MarkdownPageComponent ), ], - [ "DisplayEntity", () => @@ -209,4 +208,11 @@ export const coreComponents: ComponentTuple[] = [ "./entity-components/entity-select/display-entity/display-entity.component" ).then((c) => c.DisplayEntityComponent), ], + [ + "RelatedEntities", + () => + import( + "./entity-components/entity-details/related-entities/related-entities.component" + ).then((c) => c.RelatedEntitiesComponent), + ], ]; diff --git a/src/app/core/entity-components/entity-details/related-entities/related-entities.component.html b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.html new file mode 100644 index 0000000000..a65beb6f3b --- /dev/null +++ b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.html @@ -0,0 +1,6 @@ + diff --git a/src/app/core/entity-components/entity-details/related-entities/related-entities.component.spec.ts b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.spec.ts new file mode 100644 index 0000000000..d8d06e006e --- /dev/null +++ b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { RelatedEntitiesComponent } from "./related-entities.component"; +import { MockedTestingModule } from "../../../../utils/mocked-testing.module"; +import { EntityMapperService } from "../../../entity/entity-mapper.service"; +import { Child } from "../../../../child-dev-project/children/model/child"; +import { ChildSchoolRelation } from "../../../../child-dev-project/children/model/childSchoolRelation"; + +describe("RelatedEntitiesComponent", () => { + let component: RelatedEntitiesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RelatedEntitiesComponent, MockedTestingModule.withState()], + }).compileComponents(); + + fixture = TestBed.createComponent(RelatedEntitiesComponent); + component = fixture.componentInstance; + await component.onInitFromDynamicConfig({ + entity: new Child(), + config: { + entity: ChildSchoolRelation.ENTITY_TYPE, + property: "childId", + columns: [], + }, + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should load the entities which are linked with the passed one", async () => { + const c1 = new Child(); + const c2 = new Child(); + const r1 = new ChildSchoolRelation(); + r1.childId = c1.getId(); + const r2 = new ChildSchoolRelation(); + r2.childId = c1.getId(); + const r3 = new ChildSchoolRelation(); + r3.childId = c2.getId(); + const entityMapper = TestBed.inject(EntityMapperService); + await entityMapper.saveAll([c1, c2, r1, r2, r3]); + const columns = ["start", "end", "schoolId"]; + const filter = { start: { $exists: true } } as any; + + await component.onInitFromDynamicConfig({ + entity: c1, + config: { + entity: ChildSchoolRelation.ENTITY_TYPE, + property: "childId", + columns, + filter, + }, + }); + + expect(component.columns).toBe(columns); + expect(component.data).toEqual([r1, r2, r3]); + expect(component.filter).toEqual({ ...filter, childId: c1.getId() }); + }); + + it("should create a new entity that references the related one", async () => { + const related = new Child(); + await component.onInitFromDynamicConfig({ + entity: related, + config: { + entity: ChildSchoolRelation.ENTITY_TYPE, + property: "childId", + columns: [], + }, + }); + + const newEntity = component.createNewRecordFactory()(); + + expect(newEntity instanceof ChildSchoolRelation).toBeTrue(); + expect(newEntity["childId"]).toBe(related.getId()); + }); +}); diff --git a/src/app/core/entity-components/entity-details/related-entities/related-entities.component.ts b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.ts new file mode 100644 index 0000000000..38bb1a1faf --- /dev/null +++ b/src/app/core/entity-components/entity-details/related-entities/related-entities.component.ts @@ -0,0 +1,69 @@ +import { Component } from "@angular/core"; +import { DynamicComponent } from "../../../view/dynamic-components/dynamic-component.decorator"; +import { OnInitDynamicComponent } from "../../../view/dynamic-components/on-init-dynamic-component.interface"; +import { PanelConfig } from "../EntityDetailsConfig"; +import { EntityMapperService } from "../../../entity/entity-mapper.service"; +import { Entity, EntityConstructor } from "../../../entity/model/entity"; +import { + ColumnConfig, + DataFilter, +} from "../../entity-subrecord/entity-subrecord/entity-subrecord-config"; +import { EntityRegistry } from "../../../entity/database-entity.decorator"; +import { isArrayProperty } from "../../entity-utils/entity-utils"; +import { EntitySubrecordComponent } from "../../entity-subrecord/entity-subrecord/entity-subrecord.component"; + +@DynamicComponent("RelatedEntities") +@Component({ + selector: "app-related-entities", + templateUrl: "./related-entities.component.html", + standalone: true, + imports: [EntitySubrecordComponent], +}) +export class RelatedEntitiesComponent implements OnInitDynamicComponent { + data: Entity[] = []; + columns: ColumnConfig[] = []; + filter: DataFilter; + relatedEntity: Entity; + private entityType: EntityConstructor; + private property; + private isArray = false; + + constructor( + private entityMapper: EntityMapperService, + private entities: EntityRegistry + ) {} + + async onInitFromDynamicConfig( + config: PanelConfig<{ + entity: string; + property: string; + columns: ColumnConfig[]; + filter?: DataFilter; + }> + ) { + this.relatedEntity = config.entity; + this.entityType = this.entities.get(config.config.entity); + this.property = config.config.property; + this.isArray = isArrayProperty(this.entityType, this.property); + + this.data = await this.entityMapper.loadType(this.entityType); + this.filter = { + ...config.config.filter, + [this.property]: this.isArray + ? { $elemMatch: { $eq: this.relatedEntity.getId() } } + : this.relatedEntity.getId(), + }; + this.columns = config.config.columns; + } + + createNewRecordFactory() { + // TODO has a similar purpose like FilterService.alignEntityWithFilter + return () => { + const rec = new this.entityType(); + rec[this.property] = this.isArray + ? [this.relatedEntity.getId()] + : this.relatedEntity.getId(); + return rec; + }; + } +} diff --git a/src/app/core/entity-components/entity-utils/entity-utils.ts b/src/app/core/entity-components/entity-utils/entity-utils.ts new file mode 100644 index 0000000000..45a91ac075 --- /dev/null +++ b/src/app/core/entity-components/entity-utils/entity-utils.ts @@ -0,0 +1,14 @@ +import { arrayEntitySchemaDatatype } from "../../entity/schema-datatypes/datatype-array"; +import { entityArrayEntitySchemaDatatype } from "../../entity/schema-datatypes/datatype-entity-array"; +import { EntityConstructor } from "../../entity/model/entity"; + +export function isArrayProperty( + entity: EntityConstructor, + property: string +): boolean { + const dataType = entity.schema.get(property).dataType; + return ( + dataType === arrayEntitySchemaDatatype.name || + dataType === entityArrayEntitySchemaDatatype.name + ); +} diff --git a/src/app/core/entity/database-indexing/database-indexing.service.ts b/src/app/core/entity/database-indexing/database-indexing.service.ts index 6fc6c4176e..477d0345a1 100644 --- a/src/app/core/entity/database-indexing/database-indexing.service.ts +++ b/src/app/core/entity/database-indexing/database-indexing.service.ts @@ -22,8 +22,7 @@ import { BackgroundProcessState } from "../../sync-status/background-process-sta import { Entity, EntityConstructor } from "../model/entity"; import { EntitySchemaService } from "../schema/entity-schema.service"; import { first } from "rxjs/operators"; -import { arrayEntitySchemaDatatype } from "../schema-datatypes/datatype-array"; -import { entityArrayEntitySchemaDatatype } from "../schema-datatypes/datatype-entity-array"; +import { isArrayProperty } from "../../entity-components/entity-utils/entity-utils"; /** * Manage database query index creation and use, working as a facade in front of the Database service. @@ -115,10 +114,6 @@ export class DatabaseIndexingService { return `emit(${primaryParam});`; } }; - const dataType = entity.schema.get(referenceProperty).dataType; - const isArrayProperty = - dataType === arrayEntitySchemaDatatype.name || - dataType === entityArrayEntitySchemaDatatype.name; const simpleEmit = emitParamFormatter("doc." + referenceProperty); const arrayEmit = ` @@ -134,7 +129,11 @@ export class DatabaseIndexingService { map: `(doc) => { if (!doc._id.startsWith("${entity.ENTITY_TYPE}")) return; - ${isArrayProperty ? arrayEmit : simpleEmit} + ${ + isArrayProperty(entity, referenceProperty) + ? arrayEmit + : simpleEmit + } }`, }, }, diff --git a/src/app/core/entity/entity-config.service.spec.ts b/src/app/core/entity/entity-config.service.spec.ts index 947a78e8cc..4a70f6dfd4 100644 --- a/src/app/core/entity/entity-config.service.spec.ts +++ b/src/app/core/entity/entity-config.service.spec.ts @@ -61,18 +61,6 @@ describe("EntityConfigService", () => { expect(result).toBe(config); }); - it("throws an error when trying to setting the entities up from config and they are not registered", () => { - const configWithInvalidEntities: (EntityConfig & { _id: string })[] = [ - { - _id: "entity:IDoNotExist", - attributes: [], - }, - ]; - mockConfigService.getAllConfigs.and.returnValue(configWithInvalidEntities); - - expect(() => service.setupEntitiesFromConfig()).toThrowError(); - }); - it("appends custom definitions for each entity from the config", () => { const ATTRIBUTE_1_NAME = "test1Attribute"; const ATTRIBUTE_2_NAME = "test2Attribute"; @@ -134,6 +122,55 @@ describe("EntityConfigService", () => { expect(Test.icon).toBe("users"); expect(Test.color).toBe("red"); }); + + it("should create a new subclass with the schema of the extended", () => { + const schema = { dataType: "string", label: "Dynamic Property" }; + mockConfigService.getAllConfigs.and.returnValue([ + { + _id: "entity:DynamicTest", + label: "Dynamic Test Entity", + extends: "Test", + attributes: [{ name: "dynamicProperty", schema }], + }, + ]); + + service.setupEntitiesFromConfig(); + + const dynamicEntity = entityRegistry.get("DynamicTest"); + expect(dynamicEntity.ENTITY_TYPE).toBe("DynamicTest"); + expect([...dynamicEntity.schema.entries()]).toEqual( + jasmine.arrayContaining([...Test.schema.entries()]) + ); + expect(dynamicEntity.schema.get("dynamicProperty")).toBe(schema); + const dynamicInstance = new dynamicEntity("someId"); + expect(dynamicInstance instanceof Test).toBeTrue(); + expect(dynamicInstance.getId(true)).toBe("DynamicTest:someId"); + + // it should overwrite anything in the extended entity + expect(Test.schema.has("dynamicProperty")).toBeFalse(); + const parentInstance = new Test("otherId"); + expect(parentInstance.getId(true)).toBe("Test:otherId"); + }); + + it("should subclass entity if no extension is specified", () => { + mockConfigService.getAllConfigs.and.returnValue([ + { + _id: "entity:NoExtends", + label: "DynamicTest", + attributes: [], + }, + ]); + + service.setupEntitiesFromConfig(); + + const dynamicEntity = entityRegistry.get("NoExtends"); + expect([...dynamicEntity.schema.entries()]).toEqual([ + ...Entity.schema.entries(), + ]); + const dynamicInstance = new dynamicEntity("someId"); + expect(dynamicInstance instanceof Entity).toBeTrue(); + expect(dynamicInstance.getId(true)).toBe("NoExtends:someId"); + }); }); @DatabaseEntity("Test") diff --git a/src/app/core/entity/entity-config.service.ts b/src/app/core/entity/entity-config.service.ts index 432c69c930..c986909737 100644 --- a/src/app/core/entity/entity-config.service.ts +++ b/src/app/core/entity/entity-config.service.ts @@ -37,11 +37,27 @@ export class EntityConfigService { EntityConfig & { _id: string } >(ENTITY_CONFIG_PREFIX)) { const id = config._id.substring(ENTITY_CONFIG_PREFIX.length); + if (!this.entities.has(id)) { + this.createNewEntity(id, config.extends); + } const ctor = this.entities.get(id); this.addConfigAttributes(ctor, config); } } + private createNewEntity(id: string, parent: string) { + const parentClass = this.entities.has(parent) + ? this.entities.get(parent) + : Entity; + + class DynamicClass extends parentClass { + static schema = new Map(parentClass.schema.entries()); + static ENTITY_TYPE = id; + } + + this.entities.set(id, DynamicClass); + } + /** * Appends the given (dynamic) attributes to the schema of the provided Entity. * If no arguments are provided, they will be loaded from the config @@ -135,4 +151,9 @@ export interface EntityConfig { * base route of views for this entity type */ route?: string; + + /** + * when a new entity is created, all properties from this class will also be available + */ + extends?: string; }