src/app/features/config-setup/config-import-parser.service.ts
import { Injectable } from "@angular/core";
import {
EntityListConfig,
GroupConfig,
} from "../../core/entity-list/EntityListConfig";
import {
EntityDetailsConfig,
Panel,
PanelComponent,
} from "../../core/entity-details/EntityDetailsConfig";
import { ConfigurableEnumConfig } from "../../core/basic-datatypes/configurable-enum/configurable-enum.interface";
import { EntitySchemaField } from "../../core/entity/schema/entity-schema-field";
import { ConfigFieldRaw } from "./config-field.raw";
import { ViewConfig } from "../../core/config/dynamic-routing/view-config.interface";
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",
})
export class ConfigImportParserService {
static NOT_CONFIGURED_KEY = "NOT_CONFIGURED";
private static DEFAULT_CONFIG_KEYS = [
"appConfig",
"appConfig:usage-analytics",
"navigationMenu",
"view:",
// TODO what do we do with these?
"enum:interaction-type",
"enum:warning-levels",
"view:note",
"view:attendance",
"view:attendance/add-day",
"view:attendance/recurring-activity",
"view:attendance/recurring-activity/:id",
"view:admin",
"view:admin/conflicts",
"view:admin/config-import",
"view:import",
"view:user",
"view:user/:id",
"view:help",
];
enumsAvailable: Map<string, ConfigurableEnumConfig> = new Map();
existingEnumHashmap: Map<string, string> = new Map();
generatedViews: Map<
string,
ViewConfig<EntityListConfig> | ViewConfig<EntityDetailsConfig>
> = new Map();
private reset() {
this.enumsAvailable.clear();
this.existingEnumHashmap.clear();
this.generatedViews.clear();
// TODO: how to get the id for already existing enums in database?
}
parseImportDefinition(
configRaw: ConfigFieldRaw[],
entityName: string,
includingDefaultConfigs: boolean,
): GeneratedConfig {
this.reset();
const entity: EntityConfig = { attributes: {} };
for (const f of configRaw) {
if (!f?.dataType) {
continue;
}
const parsedField = this.parseFieldDefinition(f, entityName);
entity.attributes[parsedField.id] = parsedField.schema;
}
const generatedConfig: GeneratedConfig = {};
if (includingDefaultConfigs) {
this.initializeDefaultValues(generatedConfig);
}
generatedConfig[EntityConfigService.PREFIX_ENTITY_CONFIG + entityName] =
entity;
// add enum configs
for (const [key, enumConfig] of this.enumsAvailable) {
generatedConfig["enum:" + key] = enumConfig;
}
// add generated list and details view configs
for (const [key, viewConfig] of this.generatedViews) {
generatedConfig["view:" + key.toLowerCase()] = viewConfig;
}
return generatedConfig;
}
private parseFieldDefinition(
fieldDef: ConfigFieldRaw,
entityType: string,
): { id: string; schema: EntitySchemaField } {
const fieldId = fieldDef.id ?? generateIdFromLabel(fieldDef.label);
const schema: EntitySchemaField = {
dataType: fieldDef.dataType,
label: fieldDef.label,
description: fieldDef.description,
};
if (
fieldDef.dataType === "single-entity-select" ||
fieldDef.dataType === "entity"
) {
schema.dataType = "entity";
schema.additional = fieldDef.additional_type_details;
}
if (fieldDef.dataType === "entity-array") {
schema.dataType = "entity-array";
schema.additional = fieldDef.additional_type_details;
}
if (
fieldDef.dataType === "enum" ||
fieldDef.dataType === "configurable-enum"
) {
schema.dataType = "configurable-enum";
schema.additional = this.generateOrMatchEnum(
fieldDef.additional_type_details,
fieldId,
);
}
if (fieldDef.dataType === "enum-multi") {
schema.dataType = "configurable-enum";
schema.isArray = true;
schema.additional = this.generateOrMatchEnum(
fieldDef.additional_type_details,
fieldId,
);
}
this.generateOrUpdateListViewConfig(fieldDef, entityType, fieldId);
this.generateOrUpdateDetailsViewConfig(fieldDef, entityType, fieldId);
deleteEmptyProperties(schema);
return { id: fieldId, schema: schema };
}
/**
* 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.
* @param enumValues values for enum options as imported string
* @param key If new enum is created, this key is used as id.
* @return The id of the matched or created configurable-enum
* @private
*/
private generateOrMatchEnum(enumValues: string, key: string): string {
if (typeof enumValues !== "string") {
return ConfigImportParserService.NOT_CONFIGURED_KEY;
}
let values = enumValues
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
values = values.filter((v, index) => values.indexOf(v) === index); // remove duplicates
// identify existing enum with same values
const hash = values.sort((a, b) => a.localeCompare(b)).join(",");
if (this.existingEnumHashmap.has(hash)) {
return this.existingEnumHashmap.get(hash);
}
// create and add new enum
const enumConfig: ConfigurableEnumConfig = values.map((v) => ({
id: v,
label: v,
}));
this.enumsAvailable.set(key, enumConfig);
this.existingEnumHashmap.set(hash, key);
return key;
}
private generateOrUpdateListViewConfig(
fieldDef: ConfigFieldRaw,
entityType: string,
fieldId: string,
) {
if (
!fieldDef.show_in_list ||
fieldDef.show_in_list.toString().length === 0
) {
return;
}
const listView: EntityListConfig =
(this.generatedViews.get(entityType)?.config as EntityListConfig) ??
this.generateEmptyListView(entityType);
for (const fieldColGroup of fieldDef.show_in_list.split(",")) {
const columnGroup = this.generateOrFindColumnGroup(
listView,
fieldColGroup.trim(),
);
columnGroup.columns.push(fieldId);
}
}
private generateEmptyListView(entityType: string): EntityListConfig {
const newListView: EntityListConfig = {
columns: [],
entityType: entityType,
title: "",
columnGroups: { groups: [] },
};
this.generatedViews.set(entityType, {
_id: entityType,
component: "EntityList",
config: newListView,
});
return newListView;
}
private generateOrFindColumnGroup(
listView: EntityListConfig,
columnGroupName: string,
) {
const existingColumnGroup = listView.columnGroups.groups.find(
(c) => c.name === columnGroupName,
);
if (existingColumnGroup) {
return existingColumnGroup;
}
const newColumnGroup: GroupConfig = { name: columnGroupName, columns: [] };
listView.columnGroups.groups.push(newColumnGroup);
return newColumnGroup;
}
private generateOrUpdateDetailsViewConfig(
fieldDef: ConfigFieldRaw,
entityType: string,
fieldId: string,
) {
if (
!fieldDef.show_in_details ||
fieldDef.show_in_details.toString().length === 0
) {
return;
}
const detailsView: EntityDetailsConfig =
(this.generatedViews.get(entityType + "/:id")
?.config as EntityDetailsConfig) ??
this.generateEmptyDetailsView(entityType + "/:id", entityType);
for (const detailsTab of fieldDef.show_in_details.split(",")) {
const [tabName, fieldGroupName] = detailsTab.split(":");
const panel: PanelComponent = this.generateOrFindDetailsPanel(
detailsView,
tabName.trim(),
);
let fieldGroupIndex = 0;
if (fieldGroupName) {
if (!panel.config.headers) {
panel.config.headers = [null]; // initialize headers with a default for fields without header
}
fieldGroupIndex = panel.config.headers.findIndex(
(header) => header === fieldGroupName,
);
if (fieldGroupIndex === -1) {
panel.config.headers.push(fieldGroupName);
fieldGroupIndex = panel.config.headers.length - 1;
}
}
extendArray(panel.config.cols, fieldGroupIndex + 1);
panel.config.cols[fieldGroupIndex].push(fieldId);
}
}
private generateEmptyDetailsView(
viewId: string,
entityType: string,
): EntityDetailsConfig {
const newDetailsView = {
entityType: entityType,
icon: "child",
panels: [],
title: "",
};
this.generatedViews.set(viewId, {
_id: viewId,
component: "EntityDetails",
config: newDetailsView,
});
return newDetailsView;
}
private generateOrFindDetailsPanel(
detailsView: EntityDetailsConfig,
panelName: string,
): PanelComponent {
const existingPanel = detailsView.panels.find(
(c) => c.title === panelName && c.components[0].component === "Form",
);
if (existingPanel) {
return existingPanel.components[0];
}
const newPanel: Panel = {
title: panelName,
components: [{ title: "", component: "Form", config: { cols: [[]] } }],
};
detailsView.panels.push(newPanel);
return newPanel.components[0];
}
private initializeDefaultValues(generatedConfig: GeneratedConfig) {
generatedConfig["enum:" + ConfigImportParserService.NOT_CONFIGURED_KEY] = [
{
id: ConfigImportParserService.NOT_CONFIGURED_KEY,
label: "NOT CONFIGURED",
},
];
for (const key of ConfigImportParserService.DEFAULT_CONFIG_KEYS) {
generatedConfig[key] = defaultJsonConfig[key];
}
}
}
export type GeneratedConfig = {
[key: string]: EntityConfig | ViewConfig | ConfigurableEnumConfig;
};
/**
* Delete properties on an object which are "empty", to clean up redundant details.
*/
function deleteEmptyProperties(data: Object) {
for (const k of Object.keys(data)) {
if (data[k] === null || data[k] === undefined) {
delete data[k];
}
}
}
/**
* Pad an array with additional empty arrays up to the given new size.
* @param array
* @param newSize
*/
function extendArray(array: any, newSize: number) {
while (newSize > array.length) array.push([]);
}