diff --git a/src/utils/codemirror.js b/src/utils/codemirror.js index face68c1..10bd1504 100644 --- a/src/utils/codemirror.js +++ b/src/utils/codemirror.js @@ -3,5 +3,5 @@ import lodash from "lodash"; export const refreshCodeMirror = (containerId) => { const container = document.getElementById(containerId); const editors = container.getElementsByClassName("CodeMirror"); - lodash.each(editors, (cm) => cm.CodeMirror.refresh()); + lodash.forEach(editors, (cm) => cm.CodeMirror.refresh()); }; diff --git a/src/utils/index.js b/src/utils/index.js index 085165ac..c27687fd 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -19,6 +19,7 @@ import { sortKeysDeepForObject, stringifyObject, } from "./object"; +import { getSchemaWithDependencies } from "./schemas"; import { getSearchQuerySelector } from "./selector"; import { convertArabicToRoman, @@ -28,6 +29,7 @@ import { removeNewLinesAndExtraSpaces, toFixedLocale, } from "./str"; +import { mapTree } from "./tree"; import { containsEncodedComponents } from "./url"; import { getUUID } from "./uuid"; @@ -67,4 +69,6 @@ export { addUnit, removeUnit, replaceUnit, + mapTree, + getSchemaWithDependencies, }; diff --git a/src/utils/schemas.js b/src/utils/schemas.js index bb6c409d..f1ed2a79 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.js @@ -1,3 +1,5 @@ +import lodash from "lodash"; + import { JSONSchemasInterface } from "../JSONSchemasInterface"; export const schemas = {}; @@ -19,3 +21,103 @@ export function getSchemaByClassName(className) { export function registerClassName(className, schemaId) { schemas[className] = schemaId; } + +export function typeofSchema(schema) { + if (lodash.has(schema, "type")) { + return schema.type; + } + if (lodash.has(schema, "properties")) { + return "object"; + } + if (lodash.has(schema, "items")) { + return "array"; + } +} + +function getEnumValues(nodes) { + if (!nodes.length) return {}; + return { + enum: nodes.map((node) => lodash.get(node, node.dataSelector.value)), + }; +} + +function getEnumNames(nodes) { + if (!nodes.length) return {}; + return { + enumNames: nodes.map((node) => lodash.get(node, node.dataSelector.name)), + }; +} + +/** + * @summary Recursively generate `dependencies` for RJSF schema based on tree. + * @param {Object[]} nodes - Array of nodes (e.g. `[tree]` or `node.children`) + * @returns {{}|{dependencies: {}}} + */ +export function buildDependencies(nodes) { + if (nodes.length === 0 || nodes.every((n) => !n.children?.length)) return {}; + const parentKey = nodes[0].dataSelector.key; + const childKey = nodes[0].children[0].dataSelector.key; + return { + dependencies: { + [parentKey]: { + oneOf: nodes.map((node) => { + return { + properties: { + [parentKey]: { + ...getEnumValues([node]), + ...getEnumNames([node]), + }, + [childKey]: { + ...getEnumValues(node.children), + ...getEnumNames(node.children), + }, + }, + ...buildDependencies(node.children), + }; + }), + }, + }, + }; +} + +/** + * Combine schema and dependencies block for usage with react-jsonschema-form (RJSF) + * @param {Object} schema - Schema + * @param {String} schemaId - Schema id (takes precedence over `schema` when both are provided) + * @param {Object[]} nodes - Array of nodes + * @param {Boolean} modifyProperties - Whether properties in main schema should be modified (add `enum` and `enumNames`) + * @returns {{}|{[p: string]: *}} - RJSF schema + */ +export function getSchemaWithDependencies({ + schema = {}, + schemaId, + nodes, + modifyProperties = false, +}) { + const mainSchema = schemaId ? JSONSchemasInterface.schemaById(schemaId) : schema; + + if (!lodash.isEmpty(mainSchema) && typeofSchema(mainSchema) !== "object") { + console.error("getSchemaWithDependencies() only accepts schemas of type 'object'"); + return {}; + } + + // RJSF does not automatically render dropdown widget if `enum` is not present + if (modifyProperties && nodes.length) { + const mod = { + [nodes[0].dataSelector.key]: { + ...getEnumNames(nodes), + ...getEnumValues(nodes), + }, + }; + lodash.forEach(mod, (extraFields, key) => { + if (lodash.has(mainSchema, `properties.${key}`)) { + mainSchema.properties[key] = { ...mainSchema.properties[key], ...extraFields }; + } + }); + } + + return { + ...(schemaId ? mainSchema : schema), + ...buildDependencies(nodes), + }; +} diff --git a/src/utils/tree.js b/src/utils/tree.js new file mode 100644 index 00000000..e09af340 --- /dev/null +++ b/src/utils/tree.js @@ -0,0 +1,16 @@ +/** + * @summary Return nodes with `fn` function applied to each node. + * Note that the function `fn` must take a node as an argument and must return a node object. + * @param {Object[]} nodes - Array of nodes + * @param {Function} fn - function to be applied to each node + * @returns {Object[]} - Result of map + */ +export function mapTree(nodes, fn) { + return nodes.map((node) => { + const mappedNode = fn(node); + if (node?.children?.length) { + mappedNode.children = mapTree(node.children, fn); + } + return mappedNode; + }); +} diff --git a/tests/utils.schemas.tests.js b/tests/utils.schemas.tests.js new file mode 100644 index 00000000..584e7b40 --- /dev/null +++ b/tests/utils.schemas.tests.js @@ -0,0 +1,184 @@ +import { expect } from "chai"; + +import { buildDependencies, getSchemaWithDependencies, typeofSchema } from "../src/utils/schemas"; + +describe("RJSF schema", () => { + const TREE = { + path: "/dft", + dataSelector: { key: "type", value: "data.type.slug", name: "data.type.name" }, + data: { + type: { + slug: "dft", + name: "Density Functional Theory", + }, + }, + children: [ + { + path: "/dft/lda", + dataSelector: { + key: "subtype", + value: "data.subtype.slug", + name: "data.subtype.name", + }, + data: { + subtype: { + slug: "lda", + name: "LDA", + }, + }, + children: [ + { + path: "/dft/lda/svwn", + dataSelector: { + key: "functional", + value: "data.functional.slug", + name: "data.functional.name", + }, + data: { + functional: { + slug: "svwn", + name: "SVWN", + }, + }, + }, + { + path: "/dft/lda/pz", + dataSelector: { + key: "functional", + value: "data.functional.slug", + name: "data.functional.name", + }, + data: { + functional: { + slug: "pz", + name: "PZ", + }, + }, + }, + ], + }, + { + path: "/dft/gga", + dataSelector: { + key: "subtype", + value: "data.subtype.slug", + name: "data.subtype.name", + }, + data: { + subtype: { + slug: "gga", + name: "GGA", + }, + }, + children: [ + { + path: "/dft/gga/pbe", + dataSelector: { + key: "functional", + value: "data.functional.slug", + name: "data.functional.name", + }, + data: { + functional: { + slug: "pbe", + name: "PBE", + }, + }, + }, + { + path: "/dft/gga/pw91", + dataSelector: { + key: "functional", + value: "data.functional.slug", + name: "data.functional.name", + }, + data: { + functional: { + slug: "pw91", + name: "PW91", + }, + }, + }, + ], + }, + ], + }; + const DFT_SCHEMA = { + type: "object", + properties: { + type: { + type: "string", + }, + subtype: { + type: "string", + }, + functional: { + type: "string", + }, + }, + }; + + it("dependencies block can be created from tree", () => { + const dependencies = buildDependencies([TREE]); + + const [dftCase] = dependencies.dependencies.type.oneOf; + expect(dftCase.properties.subtype.enum).to.have.ordered.members(["lda", "gga"]); + expect(dftCase.properties.subtype.enumNames).to.have.ordered.members(["LDA", "GGA"]); + + const [ldaCase, ggaCase] = dftCase.dependencies.subtype.oneOf; + expect(ldaCase.properties.subtype.enum).to.have.length(1); + expect(ldaCase.properties.functional.enum).to.have.ordered.members(["svwn", "pz"]); + expect(ldaCase.properties.functional.enumNames).to.have.ordered.members(["SVWN", "PZ"]); + expect(ldaCase).to.not.have.property("dependencies"); + + expect(ggaCase.properties.subtype.enum).to.have.length(1); + expect(ggaCase.properties.functional.enum).to.have.ordered.members(["pbe", "pw91"]); + expect(ggaCase.properties.functional.enumNames).to.have.ordered.members(["PBE", "PW91"]); + expect(ggaCase).to.not.have.property("dependencies"); + }); + + it("can be created with dependencies from schema", () => { + const rjsfSchema = getSchemaWithDependencies({ + schema: DFT_SCHEMA, + nodes: [TREE], + }); + expect(rjsfSchema.type).to.be.eql(DFT_SCHEMA.type); + expect(rjsfSchema.properties).to.be.eql(DFT_SCHEMA.properties); + expect(rjsfSchema).to.have.property("dependencies"); + }); + + it("enum and enumNames can be added to schema properties", () => { + const rjsfSchema = getSchemaWithDependencies({ + schema: DFT_SCHEMA, + nodes: [TREE], + modifyProperties: true, + }); + // console.log(JSON.stringify(rjsfSchema, null, 4)); + expect(rjsfSchema.type).to.be.eql(DFT_SCHEMA.type); + expect(rjsfSchema.properties.type).to.have.property("enum"); + expect(rjsfSchema.properties.type.enum).to.be.eql(["dft"]); + expect(rjsfSchema.properties.type).to.have.property("enumNames"); + expect(rjsfSchema.properties.type.enumNames).to.be.eql(["Density Functional Theory"]); + expect(rjsfSchema).to.have.property("dependencies"); + }); +}); + +describe("Schema utility", () => { + const schemas = [ + ["string", { type: "string" }], + ["integer", { type: "integer" }], + ["number", { type: "number" }], + ["object", { type: "object" }], + ["array", { type: "array" }], + ]; + const objSchemaNoType = { properties: { name: { type: "string" } } }; + const arraySchemaNoType = { items: { type: "number" } }; + it("type can be determined correctly", () => { + schemas.forEach(([type, schema]) => { + const currentType = typeofSchema(schema); + expect(currentType).to.be.equal(type); + }); + expect(typeofSchema(objSchemaNoType)).to.be.equal("object"); + expect(typeofSchema(arraySchemaNoType)).to.be.equal("array"); + }); +}); diff --git a/tests/utils.tree.tests.js b/tests/utils.tree.tests.js new file mode 100644 index 00000000..51bd1db7 --- /dev/null +++ b/tests/utils.tree.tests.js @@ -0,0 +1,31 @@ +import { expect } from "chai"; + +import { mapTree } from "../src/utils"; + +describe("Tree data structure", () => { + const TREE = { + path: "/A", + children: [ + { + path: "/A/B", + children: [ + { + path: "/A/B/C", + }, + ], + }, + { + path: "/A/D", + }, + ], + }; + it("map", () => { + const [mappedTree] = mapTree([TREE], (node) => { + return { ...node, foo: "bar" }; + }); + expect(mappedTree).to.have.property("foo", "bar"); + expect(mappedTree.children[0]).to.have.property("foo", "bar"); + expect(mappedTree.children[0].children[0]).to.have.property("foo", "bar"); + expect(mappedTree.children[1]).to.have.property("foo", "bar"); + }); +});