Skip to content

Commit

Permalink
feat: new entities can be completely defined through the config (#1670)
Browse files Browse the repository at this point in the history
closes #1056

Co-authored-by: Sebastian <sebastian.leidig@gmail.com>
  • Loading branch information
TheSlimvReal and sleidig authored Jan 24, 2023
1 parent a7cdc75 commit db457c4
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 20 deletions.
8 changes: 7 additions & 1 deletion src/app/core/core-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,18 @@ export const coreComponents: ComponentTuple[] = [
(c) => c.MarkdownPageComponent
),
],

[
"DisplayEntity",
() =>
import(
"./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),
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<app-entity-subrecord
[records]="data"
[filter]="filter"
[columns]="columns"
[newRecordFactory]="createNewRecordFactory()"
></app-entity-subrecord>
Original file line number Diff line number Diff line change
@@ -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<RelatedEntitiesComponent>;

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());
});
});
Original file line number Diff line number Diff line change
@@ -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<Entity>;
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<Entity>;
}>
) {
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;
};
}
}
14 changes: 14 additions & 0 deletions src/app/core/entity-components/entity-utils/entity-utils.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = `
Expand All @@ -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
}
}`,
},
},
Expand Down
61 changes: 49 additions & 12 deletions src/app/core/entity/entity-config.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions src/app/core/entity/entity-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

0 comments on commit db457c4

Please sign in to comment.