diff --git a/lib/AppContext.js b/lib/AppContext.js index 482561a0..670f3ef1 100644 --- a/lib/AppContext.js +++ b/lib/AppContext.js @@ -7,10 +7,7 @@ import { findLocalesForLangcodes } from '@/lib/utils/i18n'; import { Template, findSlotNamesForClass } from '@/lib/utils/templates'; import { wait } from '@/lib/utils/general'; import { invert, removeNumericKeys } from '@/lib/utils/objects'; -import { - setup1M, - buildSchemaTree -} from '@/lib/utils/1m'; +import { setup1M, buildSchemaTree } from '@/lib/utils/1m'; import { createDataHarmonizerContainer, createDataHarmonizerTab } from '@/web'; function getTemplatePath() { @@ -23,7 +20,8 @@ function getTemplatePath() { } if (templatePath === null || typeof templatePath === 'undefined') { const schema_name = Object.keys(menu)[0]; - const template_name = Object.keys(menu[schema_name])[0]; return `${schema_name}/${template_name}`; + const template_name = Object.keys(menu[schema_name])[0]; + return `${schema_name}/${template_name}`; } return templatePath; } @@ -49,26 +47,22 @@ export class AppContext { // Method to bind event listeners bindEventListeners() { - $(document).on('dhTabChange', (event, data) => { const { specName } = data; - console.log('dhTabChange') - - this.setCurrentDataHarmonizer(specName); + this.setCurrentDataHarmonizer(specName); // Should trigger Toolbar to refresh sections $(document).trigger('dhCurrentChange', { - dh: this.getCurrentDataHarmonizer() + dh: this.getCurrentDataHarmonizer(), }); }); - + $(document).on('dhCurrentSelectionChange', (event, data) => { const { currentSelection } = data; - console.log('dhCurrentSelectionChange') + console.log('dhCurrentSelectionChange'); this.currentSelection = currentSelection; }); - } - + async reload( template_path, overrides = { locale: null, forced_schema: null } @@ -79,7 +73,10 @@ export class AppContext { const isForcedSchemaProvided = overrides.forced_schema !== null; const isLocaleChange = overrides.locale !== null; - const clearAndSetup = async (data_harmonizers = {}, forced_schema = null) => { + const clearAndSetup = async ( + data_harmonizers = {}, + forced_schema = null + ) => { this.clearInterface(); return this.setupDataHarmonizers({ data_harmonizers, @@ -119,8 +116,8 @@ export class AppContext { this.template = await Template.create(template_name, options); return this; } - - /** + + /** * Run void function behind loading screen. * Adds function to end of call queue. Does not handle functions with return * vals, unless the return value is a promise. Even then, it only waits for the @@ -221,7 +218,7 @@ export class AppContext { return acc; } ); - + let translated_sections = {}; let default_sections = {}; Object.keys(translation.schema.classes) @@ -320,44 +317,33 @@ export class AppContext { schema_name, export_formats ) { - let data_harmonizers = {}; Object.entries(schema_tree) - .filter(([cls_key]) => cls_key !== 'Container') - .forEach((obj, index) => { - if (obj.length > 0) { - const [cls_key, spec] = obj; - - // if it shares a key with another class which is its parent, this DH must be a child - const is_child = spec.shared_keys.some( - (shared_key_spec) => shared_key_spec.relation === 'parent' - ); - - const dhId = `data-harmonizer-grid-${index}`; - if (!document.getElementById(dhId)) { - const dhSubroot = createDataHarmonizerContainer( - dhId, - index === 0 - ); - - const dhTab = createDataHarmonizerTab( - dhId, - spec.name, - index === 0 + .filter(([cls_key]) => cls_key !== 'Container') + .forEach((obj, index) => { + if (obj.length > 0) { + const [cls_key, spec] = obj; + + // if it shares a key with another class which is its parent, this DH must be a child + const is_child = spec.shared_keys.some( + (shared_key_spec) => shared_key_spec.relation === 'parent' ); - dhTab.addEventListener('click', () => { - $(document).trigger('dhTabChange', { - specName: spec.name, + + const dhId = `data-harmonizer-grid-${index}`; + if (!document.getElementById(dhId)) { + const dhSubroot = createDataHarmonizerContainer(dhId, index === 0); + + const dhTab = createDataHarmonizerTab(dhId, spec.name, index === 0); + dhTab.addEventListener('click', () => { + $(document).trigger('dhTabChange', { + specName: spec.name, + }); }); - }); - - // Different classes have different slots allocated to them. field_filters narrows to those slots. - // idempotent: running this over the same initialization twice is expensive but shouldn't lose state - // in 1-M, different DataHarmonizers have fewer rows to start with and visible if a child. Override the default HoT settings. - data_harmonizers[spec.name] = new DataHarmonizer( - dhSubroot, - this, - { + + // Different classes have different slots allocated to them. field_filters narrows to those slots. + // idempotent: running this over the same initialization twice is expensive but shouldn't lose state + // in 1-M, different DataHarmonizers have fewer rows to start with and visible if a child. Override the default HoT settings. + data_harmonizers[spec.name] = new DataHarmonizer(dhSubroot, this, { loadingScreenRoot: document.body, class_assignment: cls_key, field_filters: findSlotNamesForClass(schema, cls_key), @@ -367,21 +353,20 @@ export class AppContext { // colWidths: is_child ? 256 : undefined, // TODO: Workaround, possibly due to too small section column on child tables colWidths: 256, }, + }); + data_harmonizers[spec.name].useSchema( + schema, + export_formats, + schema_name + ); + + // hide the single row if child + if (is_child) { + data_harmonizers[spec.name].filterAll(); } - ); - data_harmonizers[spec.name].useSchema( - schema, - export_formats, - schema_name - ); - - // hide the single row if child - if (is_child) { - data_harmonizers[spec.name].filterAll(); } } - } - }); + }); delete data_harmonizers[undefined]; return data_harmonizers; // Return the created data harmonizers if needed } diff --git a/lib/Toolbar.js b/lib/Toolbar.js index 6a7eabda..eeae6677 100644 --- a/lib/Toolbar.js +++ b/lib/Toolbar.js @@ -14,15 +14,12 @@ import { importJsonFile, } from '@/lib/utils/files'; import { nullValuesToString, isEmptyUnitVal } from '@/lib/utils/general'; -import { - MULTIVALUED_DELIMITER, - titleOverText, -} from '@/lib/utils/fields'; +import { MULTIVALUED_DELIMITER, titleOverText } from '@/lib/utils/fields'; import { findBestLocaleMatch, templatePathForSchemaURI, rangeToContainerClass, - LocaleNotSupportedError + LocaleNotSupportedError, } from '@/lib/utils/templates'; import { diff --git a/lib/editors/KeyValueEditor.js b/lib/editors/KeyValueEditor.js index deedfee2..cab97e40 100644 --- a/lib/editors/KeyValueEditor.js +++ b/lib/editors/KeyValueEditor.js @@ -1,5 +1,6 @@ import Handsontable from 'handsontable'; import { MULTIVALUED_DELIMITER, titleOverText } from '@/lib/utils/fields'; +import { isEmptyUnitVal } from '@/lib/utils/general'; // Derived from: https://jsfiddle.net/handsoncode/f0b41jug/ @@ -134,11 +135,11 @@ export const keyValueListRenderer = function ( cellProperties ) { // Call the autocomplete renderer to ensure default styles and behavior are applied - - const item = cellProperties.source.find(({ _id }) => _id === value); Handsontable.renderers .getRenderer('autocomplete') - .apply(this, [hot, TD, row, col, prop, item, cellProperties]); + .apply(this, [hot, TD, row, col, prop, value, cellProperties]); + + const item = cellProperties.source.find(({ _id }) => _id === value); if (item) { // Use the label as the display value but keep the _id as the stored value const label = item.label; @@ -156,7 +157,7 @@ export const multiKeyValueListRenderer = (field) => { .getRenderer('autocomplete') .apply(this, [hot, TD, row, col, prop, value, cellProperties]); - if (!(value === null || value === '' || typeof value === 'undefined')) { + if (!isEmptyUnitVal(value)) { const label = value .split(MULTIVALUED_DELIMITER) .map((key) => titleOverText(merged_permissible_values[key])) diff --git a/lib/utils/1m.js b/lib/utils/1m.js index e6af65c7..9fa250d4 100644 --- a/lib/utils/1m.js +++ b/lib/utils/1m.js @@ -1,4 +1,3 @@ - import * as $ from 'jquery'; import { looseMatchInObject } from '@/lib/utils/objects'; import { tap, isEmptyUnitVal } from '@/lib/utils/general'; @@ -17,10 +16,10 @@ function getColumnIndexByFieldName(data_harmonizer, shared_slot_name) { } function getSharedKeyName(schemaTree, parentName, childName) { - const sharedKey = schemaTree[parentName].shared_keys.find( - (el) => el.related_concept === childName - ); - return sharedKey ? sharedKey.name : null; + const sharedKey = schemaTree[parentName].shared_keys.find( + (el) => el.related_concept === childName + ); + return sharedKey ? sharedKey.name : null; } /** @@ -103,72 +102,102 @@ function visitSchemaTree(schema_tree, callback = tap, next = 'Container') { */ function makeColumnReadOnly(data_harmonizer, shared_key_name) { const hot = data_harmonizer.hot; - const columnIndex = getColumnIndexByFieldName(data_harmonizer, shared_key_name); + const columnIndex = getColumnIndexByFieldName( + data_harmonizer, + shared_key_name + ); if (columnIndex >= 0 && columnIndex < hot.countCols()) { updateColumnSettings(hot, columnIndex, { readOnly: true }); } else { - console.error(`makeColumnReadOnly: Column not found for '${shared_key_name}'`); + console.error( + `makeColumnReadOnly: Column not found for '${shared_key_name}'` + ); } } +// closure to keep track of row data before removal +const createRowDataManager = (hot, contex_menu_callback) => { + let rowDataBeforeRemoval = {}; + + return { + beforeRemoveRow: function (index, amount, physicalRows) { + for (let i = 0; i < amount; i++) { + let rowIndex = physicalRows[i]; + let rowData = hot.getDataAtRow(rowIndex); + rowDataBeforeRemoval[rowIndex] = rowData; // Store the row data + } + }, + afterRemoveRow: function (index, amount, physicalRows, source) { + for (let i = 0; i < amount; i++) { + let rowIndex = physicalRows[i]; + let rowData = rowDataBeforeRemoval[rowIndex]; + // Custom logic using the rowData + contex_menu_callback(rowData, source); + } + // Clear the temporary storage + rowDataBeforeRemoval = {}; + }, + }; +}; + /** * Attaches a change event handler to a specific column within a Data Harmonizer instance. * @param {DataHarmonizer} data_harmonizer - The Data Harmonizer instance. * @param {string} shared_key_name - The name of the tabular column to monitor for changes. * @param {Function} callback - The callback function executed when the column data changes. */ -function setupSharedColumn( - data_harmonizer, - shared_key_name, - change_callback, - contex_menu_callback, - undo_redo_callback -) { - const hot = data_harmonizer.hot; - - // Get the index of the column based on the shared_key_name - const columnIndex = getColumnIndexByFieldName( - data_harmonizer, - shared_key_name - ); - - // Check if the column index was found properly - if (columnIndex === -1) { - console.error( - `setupSharedColumn: Column with the name '${shared_key_name}' not found.` - ); - } else { - // Initialize the row data manager - const rowDataManager = createRowDataManager(contex_menu_callback); - - // Add the beforeRemoveRow and afterRemoveRow hooks using addHook - hot.addHook('beforeRemoveRow', rowDataManager.beforeRemoveRow); - hot.addHook('afterRemoveRow', rowDataManager.afterRemoveRow); - - // Listen for changes using the afterChange hook of Handsontable - hot.addHook('afterChange', (changes, source) => { - // changes is a 2D array containing information about each change - // Each change in the array changes is of the form: [row, prop/col, oldVal, newVal] - if (changes && source !== 'loadData') { - // Ignore initial load changes - changes.forEach(([, , oldVal, newVal]) => { - change_callback(changes, source, oldVal, newVal); - }); - } - }); - - hot.addHook('afterUndo', function (action) { - console.log('undo', action); - undo_redo_callback('undo', action); - }); - - hot.addHook('afterRedo', function (action) { - console.log('redo', action); - undo_redo_callback('redo', action); - }); - } -} +// function setupSharedColumn( +// data_harmonizer, +// shared_key_name, +// change_callback, +// contex_menu_callback, +// undo_redo_callback +// ) { +// const hot = data_harmonizer.hot; + +// // Get the index of the column based on the shared_key_name +// const columnIndex = getColumnIndexByFieldName( +// data_harmonizer, +// shared_key_name +// ); + +// // Check if the column index was found properly +// if (columnIndex === -1) { +// console.error( +// `setupSharedColumn: Column with the name '${shared_key_name}' not found.` +// ); +// } else { +// // Initialize the row data manager +// const rowDataManager = createRowDataManager(hot, contex_menu_callback); + +// // Add the beforeRemoveRow and afterRemoveRow hooks using addHook +// hot.addHook('beforeRemoveRow', rowDataManager.beforeRemoveRow); +// hot.addHook('afterRemoveRow', rowDataManager.afterRemoveRow); + +// // Listen for changes using the afterChange hook of Handsontable +// hot.addHook('afterChange', (changes, source) => { +// // changes is a 2D array containing information about each change +// // Each change in the array changes is of the form: [row, prop/col, oldVal, newVal] +// if (changes && source !== 'loadData') { +// // Ignore initial load changes +// changes.forEach(([, , oldVal, newVal]) => { +// change_callback(changes, source, oldVal, newVal); +// }); +// } +// }); + +// hot.addHook('afterUndo', function (action) { +// console.log('undo', action); +// undo_redo_callback('undo', action); +// }); + +// hot.addHook('afterRedo', function (action) { +// console.log('redo', action); +// undo_redo_callback('redo', action); +// }); +// } +// } /** * Creates and attaches shared key handlers to a data harmonizer for a given schema tree node. @@ -176,150 +205,150 @@ function setupSharedColumn( * @param {Object} schema_tree_node - The schema tree node containing the shared keys and child references. * @param {Object} data_harmonizers - A collection of data harmonizer instances mapped by schema node classes. */ -function makeSharedKeyHandler( - data_harmonizers, - data_harmonizer, - schema_tree_node -) { - const modifyRow = (hot, rowIndex, col, newVal) => { - // need to use setSourceDataAtCell rather than setDataAtCell, or else the 'physical' dataset isn't edited - // when only modifying the 'non-physical' dataset (returned by getData), there were rows that were duplicated when modified - hot.setSourceDataAtCell(rowIndex, col, newVal); - }; - - const clearRow = (hot, rowIndex) => { - const colcount = hot.countCols(); - for (let c = 0; c < colcount; c++) { - modifyRow(hot, rowIndex, c, null); - } - }; - - const updateDataHarmonizer = (data_harmonizer, [, col, oldVal, newVal]) => { - const hot = data_harmonizer.hot; - - hot.batch(() => { - const data = hot.getSourceData(); - data.every((childRow, rowIndex) => { - const oldChildVal = childRow[col]; // TODO: revise to shared key name? - const CONTINUE_SIGNAL = true; - - // modifications should only occur if there exists a non-empty parent val, the new val is different from the old child val, - // and the old and new child vals are equivalent. This mean edits will happen in "blocks". - const shouldModifyChild = - oldChildVal === oldVal && - oldChildVal !== newVal && - !isEmptyUnitVal(oldVal); - const shouldDeleteChildRow = - shouldModifyChild && isEmptyUnitVal(newVal); - const shouldRemapChildCol = - shouldModifyChild && !isEmptyUnitVal(newVal); - - // DEPRECATE - // if (isEmptyUnitVal(oldChildVal) && !isEmptyUnitVal(newVal)) { - // console.log('should create'); - // hot.setDataAtCell(rowIndex, col, newVal); - // return !CONTINUE_SIGNAL; - // } - - if (shouldDeleteChildRow) { - console.log('should delete'); - clearRow(hot, rowIndex); - } - if (shouldRemapChildCol) { - console.log('should remap'); - modifyRow(hot, rowIndex, col, newVal); - } - return CONTINUE_SIGNAL; - }); - }); - hot.render(); - }; - - const updateDataHarmonizerChildClasses = (children, changeData) => { - children.forEach((cls_key) => { - updateDataHarmonizer(data_harmonizers[cls_key], changeData); - }); - }; - - const createUpdateHandler = (/*shared_key_spec*/) => { - return (changes, source) => { - const [row, col, oldVal, newVal] = changes[0]; - if (source === 'edit') { - updateDataHarmonizerChildClasses(schema_tree_node.children, [ - row, - col, - oldVal, - newVal, - ]); - - data_harmonizers[schema_tree_node.name].hot.selectCell(row, col); - data_harmonizers[schema_tree_node.name].hot.runHooks( - 'afterSelection', - row, - col - ); - } - }; - }; - - // TODO: handle the reversing of an action taken for undo/redo on a shared key - const createUndoRedoHandler = (/* shared_key_spec */) => { - // const { name: shared_key_name } = shared_key_spec; - return (undoOrRedo, action) => { - const hot = data_harmonizers[schema_tree_node.name].hot; - const changes = action.changes; - - if (action.actionType === 'edit') { - const [row, col, oldVal, newVal] = changes[0]; - - if (undoOrRedo === 'undo') { - // Propagate the undo action to related harmonizers - updateDataHarmonizerChildClasses(schema_tree_node.children, [ - row, - col, - newVal, // In undo, revert to the old value - oldVal, // Reapply the old value - ]); - } else if (undoOrRedo === 'redo') { - // Propagate the redo action to related harmonizers - updateDataHarmonizerChildClasses(schema_tree_node.children, [ - row, - col, - oldVal, // In redo, reapply the new value - newVal, // Reapply the value that was undone - ]); - } - } else if (['remove_row', 'insert_row'].includes(action.actionType)) { - const rowIndex = action.index; - - if (undoOrRedo === 'undo') { - if (action.actionType === 'remove_row') { - hot.alter('insert_row', rowIndex); - } else if (action.actionType === 'insert_row') { - hot.alter('remove_row', rowIndex); - } - } else if (undoOrRedo === 'redo') { - if (action.actionType === 'remove_row') { - hot.alter('remove_row', rowIndex); - } else if (action.actionType === 'insert_row') { - hot.alter('insert_row', rowIndex); - } - } - hot.render(); - } - }; - }; - - schema_tree_node.shared_keys.forEach((shared_key_spec) => { - setupSharedColumn( - data_harmonizer, - shared_key_spec.name, - createUpdateHandler(shared_key_spec), - createContextMenuHandler(shared_key_spec), - createUndoRedoHandler(shared_key_spec) - ); - }); -} +// function makeSharedKeyHandler( +// data_harmonizers, +// data_harmonizer, +// schema_tree_node +// ) { +// const modifyRow = (hot, rowIndex, col, newVal) => { +// // need to use setSourceDataAtCell rather than setDataAtCell, or else the 'physical' dataset isn't edited +// // when only modifying the 'non-physical' dataset (returned by getData), there were rows that were duplicated when modified +// hot.setSourceDataAtCell(rowIndex, col, newVal); +// }; + +// const clearRow = (hot, rowIndex) => { +// const colcount = hot.countCols(); +// for (let c = 0; c < colcount; c++) { +// modifyRow(hot, rowIndex, c, null); +// } +// }; + +// const updateDataHarmonizer = (data_harmonizer, [, col, oldVal, newVal]) => { +// const hot = data_harmonizer.hot; + +// hot.batch(() => { +// const data = hot.getSourceData(); +// data.every((childRow, rowIndex) => { +// const oldChildVal = childRow[col]; // TODO: revise to shared key name? +// const CONTINUE_SIGNAL = true; + +// // modifications should only occur if there exists a non-empty parent val, the new val is different from the old child val, +// // and the old and new child vals are equivalent. This mean edits will happen in "blocks". +// const shouldModifyChild = +// oldChildVal === oldVal && +// oldChildVal !== newVal && +// !isEmptyUnitVal(oldVal); +// const shouldDeleteChildRow = +// shouldModifyChild && isEmptyUnitVal(newVal); +// const shouldRemapChildCol = +// shouldModifyChild && !isEmptyUnitVal(newVal); + +// // DEPRECATE +// // if (isEmptyUnitVal(oldChildVal) && !isEmptyUnitVal(newVal)) { +// // console.log('should create'); +// // hot.setDataAtCell(rowIndex, col, newVal); +// // return !CONTINUE_SIGNAL; +// // } + +// if (shouldDeleteChildRow) { +// console.log('should delete'); +// clearRow(hot, rowIndex); +// } +// if (shouldRemapChildCol) { +// console.log('should remap'); +// modifyRow(hot, rowIndex, col, newVal); +// } +// return CONTINUE_SIGNAL; +// }); +// }); +// hot.render(); +// }; + +// const updateDataHarmonizerChildClasses = (children, changeData) => { +// children.forEach((cls_key) => { +// updateDataHarmonizer(data_harmonizers[cls_key], changeData); +// }); +// }; + +// const createUpdateHandler = (/*shared_key_spec*/) => { +// return (changes, source) => { +// const [row, col, oldVal, newVal] = changes[0]; +// if (source === 'edit') { +// updateDataHarmonizerChildClasses(schema_tree_node.children, [ +// row, +// col, +// oldVal, +// newVal, +// ]); + +// data_harmonizers[schema_tree_node.name].hot.selectCell(row, col); +// data_harmonizers[schema_tree_node.name].hot.runHooks( +// 'afterSelection', +// row, +// col +// ); +// } +// }; +// }; + +// // TODO: handle the reversing of an action taken for undo/redo on a shared key +// const createUndoRedoHandler = (/* shared_key_spec */) => { +// // const { name: shared_key_name } = shared_key_spec; +// return (undoOrRedo, action) => { +// const hot = data_harmonizers[schema_tree_node.name].hot; +// const changes = action.changes; + +// if (action.actionType === 'edit') { +// const [row, col, oldVal, newVal] = changes[0]; + +// if (undoOrRedo === 'undo') { +// // Propagate the undo action to related harmonizers +// updateDataHarmonizerChildClasses(schema_tree_node.children, [ +// row, +// col, +// newVal, // In undo, revert to the old value +// oldVal, // Reapply the old value +// ]); +// } else if (undoOrRedo === 'redo') { +// // Propagate the redo action to related harmonizers +// updateDataHarmonizerChildClasses(schema_tree_node.children, [ +// row, +// col, +// oldVal, // In redo, reapply the new value +// newVal, // Reapply the value that was undone +// ]); +// } +// } else if (['remove_row', 'insert_row'].includes(action.actionType)) { +// const rowIndex = action.index; + +// if (undoOrRedo === 'undo') { +// if (action.actionType === 'remove_row') { +// hot.alter('insert_row', rowIndex); +// } else if (action.actionType === 'insert_row') { +// hot.alter('remove_row', rowIndex); +// } +// } else if (undoOrRedo === 'redo') { +// if (action.actionType === 'remove_row') { +// hot.alter('remove_row', rowIndex); +// } else if (action.actionType === 'insert_row') { +// hot.alter('insert_row', rowIndex); +// } +// } +// hot.render(); +// } +// }; +// }; + +// schema_tree_node.shared_keys.forEach((shared_key_spec) => { +// setupSharedColumn( +// data_harmonizer, +// shared_key_spec.name, +// createUpdateHandler(shared_key_spec), +// createContextMenuHandler(shared_key_spec), +// createUndoRedoHandler(shared_key_spec) +// ); +// }); +// } function propagateFilter( data_harmonizers, @@ -359,115 +388,113 @@ function propagateFilter( } function createFlatSchemaTree(classNames) { - return { - Container: { - tree_root: true, - children: [...classNames], - }, - ...classNames.reduce((acc, key) => { - acc[key] = { - name: key, - children: [], - shared_keys: [], - }; - return acc; - }, {}), - }; - } + return { + Container: { + tree_root: true, + children: [...classNames], + }, + ...classNames.reduce((acc, key) => { + acc[key] = { + name: key, + children: [], + shared_keys: [], + }; + return acc; + }, {}), + }; +} function createPreSchemaTree(classNames, sharedKeysPerClass) { - const treeBase = { - Container: { tree_root: true, children: classNames }, - }; + const treeBase = { + Container: { tree_root: true, children: classNames }, + }; - return classNames.reduce((acc, classKey) => { - const sharedKeys = sharedKeysPerClass[classKey] || []; - acc[classKey] = { - name: classKey, - shared_keys: sharedKeys, - children: sharedKeys - .map((item) => item.range) - .filter((range) => range !== classKey && typeof range !== 'undefined'), - }; - return acc; - }, treeBase); - } + return classNames.reduce((acc, classKey) => { + const sharedKeys = sharedKeysPerClass[classKey] || []; + acc[classKey] = { + name: classKey, + shared_keys: sharedKeys, + children: sharedKeys + .map((item) => item.range) + .filter((range) => range !== classKey && typeof range !== 'undefined'), + }; + return acc; + }, treeBase); +} function updateWithChildrenAndSharedKeys(data) { - // Use a deep clone to avoid mutating the original object - const result = JSON.parse(JSON.stringify(data)); - - Object.keys(result).forEach((key) => { - const elem = result[key]; - (elem.shared_keys || []).forEach((sk) => { - if (sk.relation === 'parent' && result[sk.related_concept]) { - const parent = result[sk.related_concept]; - - // Ensure 'children' array exists - if (!parent.children) { - parent.children = []; - } + // Use a deep clone to avoid mutating the original object + const result = JSON.parse(JSON.stringify(data)); + + Object.keys(result).forEach((key) => { + const elem = result[key]; + (elem.shared_keys || []).forEach((sk) => { + if (sk.relation === 'parent' && result[sk.related_concept]) { + const parent = result[sk.related_concept]; + + // Ensure 'children' array exists + if (!parent.children) { + parent.children = []; + } - // Add key to parent’s 'children' array if not already present - if (!parent.children.includes(key)) { - parent.children.push(key); - } + // Add key to parent’s 'children' array if not already present + if (!parent.children.includes(key)) { + parent.children.push(key); + } - // Ensure 'shared_keys' array exists - if (!parent.shared_keys) { - parent.shared_keys = []; - } + // Ensure 'shared_keys' array exists + if (!parent.shared_keys) { + parent.shared_keys = []; + } - // Check if reciprocal shared key already exists - const reciprocalExists = parent.shared_keys.some( - (rsk) => rsk.name === sk.name && rsk.related_concept === key - ); + // Check if reciprocal shared key already exists + const reciprocalExists = parent.shared_keys.some( + (rsk) => rsk.name === sk.name && rsk.related_concept === key + ); - // Add reciprocal shared key if it doesn't exist - if (!reciprocalExists) { - parent.shared_keys.push({ - name: sk.name, - related_concept: key, - relation: 'child', - }); - } + // Add reciprocal shared key if it doesn't exist + if (!reciprocalExists) { + parent.shared_keys.push({ + name: sk.name, + related_concept: key, + relation: 'child', + }); } - }); + } }); + }); - return result; - } + return result; +} - /** - * Builds a schema tree from the given schema. - * @param {Object} schema - The schema object containing "classes". - * @returns {Object|null} The schema tree object, or null if no "Container" classes are found. - */ +/** + * Builds a schema tree from the given schema. + * @param {Object} schema - The schema object containing "classes". + * @returns {Object|null} The schema tree object, or null if no "Container" classes are found. + */ export function buildSchemaTree(schema) { + const isContainerMissing = typeof schema.classes['Container'] === 'undefined'; - const isContainerMissing = typeof schema.classes['Container'] === 'undefined'; - - const classNames = Object.keys(schema.classes).filter( - (key) => key !== 'dh_interface' && key !== 'Container' - ); + const classNames = Object.keys(schema.classes).filter( + (key) => key !== 'dh_interface' && key !== 'Container' + ); - if (isContainerMissing) { - return createFlatSchemaTree(classNames); - } + if (isContainerMissing) { + return createFlatSchemaTree(classNames); + } - const sharedKeysPerClass = findSharedKeys(schema); - const preSchemaTree = createPreSchemaTree(classNames, sharedKeysPerClass); + const sharedKeysPerClass = findSharedKeys(schema); + const preSchemaTree = createPreSchemaTree(classNames, sharedKeysPerClass); - return updateWithChildrenAndSharedKeys(preSchemaTree); - - } + return updateWithChildrenAndSharedKeys(preSchemaTree); +} - // Utility to set column read-only +// Utility to set column read-only function updateColumnSettings(hot, columnIndex, options) { - const currentColumns = hot.getSettings().columns || []; - currentColumns[columnIndex] = { ...currentColumns[columnIndex], ...options }; - hot.updateSettings({ columns: currentColumns }); - } + const currentColumns = hot.getSettings().columns || []; + currentColumns[columnIndex] = { ...currentColumns[columnIndex], ...options }; + hot.updateSettings({ columns: currentColumns }); +} // Two kinds of edits to be dealt with in 1-M: // - multiedit inside of a column @@ -478,118 +505,140 @@ function updateColumnSettings(hot, columnIndex, options) { * @param {Object} data_harmonizers - An object mapping class names to Data Harmonizer instances. * @returns {Object} The same object with event handlers attached. */ -function makeColumnsReadOnly( - data_harmonizers, - schema_tree - ) { - visitSchemaTree(schema_tree, (schema_tree_node) => { - // Propagation: - // - If has children with shared_keys, add handler - // - visit children -> lock field from being edited by user (DH methods can modify it) - if (schema_tree_node.children.length > 0) { - if (!schema_tree_node.tree_root) { - schema_tree_node.children.forEach((child) => { - schema_tree[child].shared_keys.forEach((shared_key_spec_child) => { - makeColumnReadOnly( - data_harmonizers[child], - shared_key_spec_child.name - ); - }); +function makeColumnsReadOnly(data_harmonizers, schema_tree) { + visitSchemaTree(schema_tree, (schema_tree_node) => { + // Propagation: + // - If has children with shared_keys, add handler + // - visit children -> lock field from being edited by user (DH methods can modify it) + if (schema_tree_node.children.length > 0) { + if (!schema_tree_node.tree_root) { + schema_tree_node.children.forEach((child) => { + schema_tree[child].shared_keys.forEach((shared_key_spec_child) => { + makeColumnReadOnly( + data_harmonizers[child], + shared_key_spec_child.name + ); }); - } + }); } - }); - - // NOTE: preserve memory of selection between tabs! in DH? => using outsideClickDeselects: false, // for maintaining selection between tabs - return data_harmonizers; - } + } + }); + + // NOTE: preserve memory of selection between tabs! in DH? => using outsideClickDeselects: false, // for maintaining selection between tabs + return data_harmonizers; +} function bindSelectHandlerEvents(data_harmonizers, schema_tree) { - Object.values(data_harmonizers).forEach((dh) => { - - dh.hot.addHook('afterSelection', (row, col) => { - const valueToMatch = dh.hot.getDataAtCell(row, col); - if (!isEmptyUnitVal(valueToMatch)) { - // get value at cell - // filter other data harmonizer at cell - const parent_name = dh.class_assignment; - schema_tree[parent_name].children.forEach((child_name) => { - // filter for other data in data harmonizers matching the shared ID iff the selection is not empty - // else return an empty list - const shared_key_name = getSharedKeyName(schema_tree, parent_name, child_name); - - $(document).trigger('dhCurrentSelectionChange', { - currentSelection: { - source: dh.class_assignment, - shared_key_name, - valueToMatch, - col, - } - }); - - propagateFilter( - data_harmonizers, + Object.values(data_harmonizers).forEach((dh) => { + dh.hot.addHook('afterSelection', (row, col) => { + const valueToMatch = dh.hot.getDataAtCell(row, col); + if (!isEmptyUnitVal(valueToMatch)) { + // get value at cell + // filter other data harmonizer at cell + const parent_name = dh.class_assignment; + schema_tree[parent_name].children.forEach((child_name) => { + // filter for other data in data harmonizers matching the shared ID iff the selection is not empty + // else return an empty list + const shared_key_name = getSharedKeyName( + schema_tree, + parent_name, + child_name + ); + + $(document).trigger('dhCurrentSelectionChange', { + currentSelection: { + source: dh.class_assignment, shared_key_name, - schema_tree, - child_name, - valueToMatch - ); - }); - } else { - schema_tree[dh.class_assignment].children.forEach((child_name) => { - data_harmonizers[child_name].filterAll(); + valueToMatch, + col, + }, }); - } - }); - - dh.hot.addHook('afterDeselect', () => { - $(document).trigger('dhCurrentSelectionChange', { - currentSelection: null + propagateFilter( + data_harmonizers, + shared_key_name, + schema_tree, + child_name, + valueToMatch + ); }); - + } + }); + }); + // NOTE: preserve memory of selection between tabs! in DH? => using outsideClickDeselects: false, // for maintaining selection between tabs + return data_harmonizers; +} + +export function attachSelectionHandlersToDataHarmonizers( + data_harmonizers, + schema_tree +) { + Object.values(data_harmonizers).forEach((dh) => { + dh.hot.addHook('afterSelection', (row, col) => { + const valueToMatch = dh.hot.getDataAtCell(row, col); + if (!isEmptyUnitVal(valueToMatch)) { // get value at cell // filter other data harmonizer at cell const parent_name = dh.class_assignment; schema_tree[parent_name].children.forEach((child_name) => { - const shared_key_name = getSharedKeyName(schema_tree, parent_name, child_name); + // filter for other data in data harmonizers matching the shared ID iff the selection is not empty + // else return an empty list + const shared_key_name = getSharedKeyName( + schema_tree, + parent_name, + child_name + ); + + $(document).trigger('dhCurrentSelectionChange', { + currentSelection: { + source: dh.class_assignment, + shared_key_name, + valueToMatch, + col, + }, + }); + propagateFilter( data_harmonizers, shared_key_name, schema_tree, - child_name + child_name, + valueToMatch ); }); - }); + } else { + schema_tree[dh.class_assignment].children.forEach((child_name) => { + data_harmonizers[child_name].filterAll(); + }); + } }); - - return data_harmonizers; - } -// closure to keep track of row data before removal -const createRowDataManager = (contex_menu_callback) => { - let rowDataBeforeRemoval = {}; + dh.hot.addHook('afterDeselect', () => { + $(document).trigger('dhCurrentSelectionChange', { + currentSelection: null, + }); - return { - beforeRemoveRow: function (index, amount, physicalRows) { - for (let i = 0; i < amount; i++) { - let rowIndex = physicalRows[i]; - let rowData = hot.getDataAtRow(rowIndex); - rowDataBeforeRemoval[rowIndex] = rowData; // Store the row data - } - }, - afterRemoveRow: function (index, amount, physicalRows, source) { - for (let i = 0; i < amount; i++) { - let rowIndex = physicalRows[i]; - let rowData = rowDataBeforeRemoval[rowIndex]; - // Custom logic using the rowData - contex_menu_callback(rowData, source); - } - // Clear the temporary storage - rowDataBeforeRemoval = {}; - }, - }; -}; + // get value at cell + // filter other data harmonizer at cell + const parent_name = dh.class_assignment; + schema_tree[parent_name].children.forEach((child_name) => { + const shared_key_name = getSharedKeyName( + schema_tree, + parent_name, + child_name + ); + propagateFilter( + data_harmonizers, + shared_key_name, + schema_tree, + child_name + ); + }); + }); + }); + + return data_harmonizers; +} const modifyRow = (hot, rowIndex, col, newVal) => { // need to use setSourceDataAtCell rather than setDataAtCell, or else the 'physical' dataset isn't edited @@ -604,43 +653,42 @@ const clearRow = (hot, rowIndex) => { } }; -const createContextMenuHandler = (shared_key_spec) => { - return (rowData, source) => { - schema_tree_node.children.forEach((cls_key) => { - const hot = data_harmonizers[cls_key].hot; - - const col = getColumnIndexByFieldName( - data_harmonizers[cls_key], - shared_key_spec.name - ); - const colVal = rowData[col]; - - console.log(rowData, source, col, colVal); - - hot.batch(() => { - const CONTINUE_SIGNAL = true; - const data = hot.getSourceData(); - data.every((childRow, rowIndex) => { - if (source === 'ContextMenu.removeRow') { - if (childRow[col] === colVal) { - // TODO: revise to shared key name? - clearRow(hot, rowIndex); - } - } else if (source === 'ContextMenu.insertRow') { - modifyRow(hot, rowIndex, col, colVal); - return !CONTINUE_SIGNAL; - } - return CONTINUE_SIGNAL; - }); - }); - hot.render(); - }); - }; -}; +// const createContextMenuHandler = (data_harmonizers, shared_key_spec) => { +// return (rowData, source) => { +// schema_tree_node.children.forEach((cls_key) => { +// const hot = data_harmonizers[cls_key].hot; + +// const col = getColumnIndexByFieldName( +// data_harmonizers[cls_key], +// shared_key_spec.name +// ); +// const colVal = rowData[col]; + +// console.log(rowData, source, col, colVal); + +// hot.batch(() => { +// const CONTINUE_SIGNAL = true; +// const data = hot.getSourceData(); +// data.every((childRow, rowIndex) => { +// if (source === 'ContextMenu.removeRow') { +// if (childRow[col] === colVal) { +// // TODO: revise to shared key name? +// clearRow(hot, rowIndex); +// } +// } else if (source === 'ContextMenu.insertRow') { +// modifyRow(hot, rowIndex, col, colVal); +// return !CONTINUE_SIGNAL; +// } +// return CONTINUE_SIGNAL; +// }); +// }); +// hot.render(); +// }); +// }; +// }; // const handleChange = (...args) => console.log(args); const handleChange = (hot, [, col, oldVal, newVal]) => { - hot.batch(() => { const data = hot.getSourceData(); data.every((childRow, rowIndex) => { @@ -653,10 +701,8 @@ const handleChange = (hot, [, col, oldVal, newVal]) => { oldChildVal === oldVal && oldChildVal !== newVal && !isEmptyUnitVal(oldVal); - const shouldDeleteChildRow = - shouldModifyChild && isEmptyUnitVal(newVal); - const shouldRemapChildCol = - shouldModifyChild && !isEmptyUnitVal(newVal); + const shouldDeleteChildRow = shouldModifyChild && isEmptyUnitVal(newVal); + const shouldRemapChildCol = shouldModifyChild && !isEmptyUnitVal(newVal); // DEPRECATE // if (isEmptyUnitVal(oldChildVal) && !isEmptyUnitVal(newVal)) { @@ -676,52 +722,71 @@ const handleChange = (hot, [, col, oldVal, newVal]) => { return CONTINUE_SIGNAL; }); }); - }; function parentBroadcastsCRUD(data_harmonizers, schema_tree) { // look for schema_tree - Object.values(schema_tree).forEach(node => { + Object.values(schema_tree).forEach((node) => { if (!node.tree_root) { - const propagatesChangesOnTheseKeys = node.shared_keys.filter(el => el.relation === 'child') + const propagatesChangesOnTheseKeys = node.shared_keys.filter( + (el) => el.relation === 'child' + ); const isParent = propagatesChangesOnTheseKeys.length > 0; if (isParent) { // broadcast CRUD // Create and dispatch a custom event - propagatesChangesOnTheseKeys.forEach(shared_key => { + propagatesChangesOnTheseKeys.forEach((shared_key) => { // afterChange - data_harmonizers[node.name].hot.addHook('afterChange', (changes, source) => { - if (source === 'loadData') { - return; // don't act on changes caused by loading data - } - if (changes) { - changes.forEach(function([row, prop, oldValue, newValue]) { - const event = new CustomEvent('handsontableUpdate', { - detail: { row, prop, oldValue, newValue, sourceTable: node.name, sharedKey: shared_key } + data_harmonizers[node.name].hot.addHook( + 'afterChange', + (changes, source) => { + if (source === 'loadData') { + return; // don't act on changes caused by loading data + } + if (changes) { + changes.forEach(function ([row, prop, oldValue, newValue]) { + const event = new CustomEvent('handsontableUpdate', { + detail: { + row, + prop, + oldValue, + newValue, + sourceTable: node.name, + sharedKey: shared_key, + }, + }); + document.dispatchEvent(event); }); - document.dispatchEvent(event); - }); + } } - }); + ); // TODO: row insertion/deletion // Add the beforeRemoveRow and afterRemoveRow hooks using addHook - // const rowDataManager = createRowDataManager(handleChange); - // hot.addHook('beforeRemoveRow', rowDataManager.beforeRemoveRow); - // hot.addHook('afterRemoveRow', rowDataManager.afterRemoveRow); + const rowDataManager = createRowDataManager( + data_harmonizers[node.name].hot, + (id) => id + ); + data_harmonizers[node.name].hot.addHook( + 'beforeRemoveRow', + rowDataManager.beforeRemoveRow + ); + data_harmonizers[node.name].hot.addHook( + 'afterRemoveRow', + rowDataManager.afterRemoveRow + ); // // TODO: undo/redo // hot.addHook('afterUndo', function (action) { // console.log('undo', action); // undo_redo_callback('undo', action); // }); - + // hot.addHook('afterRedo', function (action) { // console.log('redo', action); // undo_redo_callback('redo', action); // }); // // TODO: copy/paste - - }) + }); } else { console.warn('no parenthood defined', node.name); } @@ -731,19 +796,34 @@ function parentBroadcastsCRUD(data_harmonizers, schema_tree) { function childListensCRUD(data_harmonizers, schema_tree) { // look for schema_tree - Object.values(schema_tree).forEach(node => { + Object.values(schema_tree).forEach((node) => { if (!node.tree_root) { - const receivesEventsFrom = node.shared_keys.filter(el => el.relation === 'parent'); + const receivesEventsFrom = node.shared_keys.filter( + (el) => el.relation === 'parent' + ); const isChild = receivesEventsFrom.length > 0; if (isChild) { // listen for CRUD // afterChange // assign event to DH container? - document.addEventListener('handsontableUpdate', function(event) { - const { oldValue, newValue, sourceTable, sharedKey } = event.detail; - const childCol = getColumnIndexByFieldName(data_harmonizers[node.name], sharedKey.name); - if (receivesEventsFrom.flatMap(el => el.related_concept).includes(sourceTable)) { - handleChange(data_harmonizers[node.name].hot, [, childCol, oldValue, newValue]); + document.addEventListener('handsontableUpdate', function (event) { + const { row, oldValue, newValue, sourceTable, sharedKey } = + event.detail; + const childCol = getColumnIndexByFieldName( + data_harmonizers[node.name], + sharedKey.name + ); + if ( + receivesEventsFrom + .flatMap((el) => el.related_concept) + .includes(sourceTable) + ) { + handleChange(data_harmonizers[node.name].hot, [ + row, + childCol, + oldValue, + newValue, + ]); } }); // TODO: row insertion/deletion @@ -761,4 +841,4 @@ export const setup1M = (data_harmonizers, schema_tree) => { bindSelectHandlerEvents(data_harmonizers, schema_tree); parentBroadcastsCRUD(data_harmonizers, schema_tree); childListensCRUD(data_harmonizers, schema_tree); -} \ No newline at end of file +}; diff --git a/lib/utils/fields.js b/lib/utils/fields.js index 7aabbfe5..7f7cf306 100644 --- a/lib/utils/fields.js +++ b/lib/utils/fields.js @@ -260,4 +260,4 @@ export const formatEscapeHTML = (string) => { export function titleOverText(enm) { return typeof enm.title !== 'undefined' ? enm.title : enm.text; -} \ No newline at end of file +} diff --git a/lib/utils/objects.js b/lib/utils/objects.js index 0c78aae5..0f06d3b7 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -228,9 +228,9 @@ export function removeNumericKeys(obj) { return obj; } -export function looseMatchInObject (keys) { +export function looseMatchInObject(keys) { return (obj) => (matchKey) => { const returnIf = (obj) => (key) => key in obj ? [obj[key]] : []; return keys.flatMap(returnIf(obj)).includes(matchKey); }; -} \ No newline at end of file +} diff --git a/lib/utils/template.js b/lib/utils/template.js index 076856f9..98060e5a 100644 --- a/lib/utils/template.js +++ b/lib/utils/template.js @@ -14,7 +14,7 @@ export async function templatePathForSchemaURI(schemaURI) { // for now, this is just the manifest for (let i = 0; i < template_manifest.children.length; i++) { const template = template_manifest.children[i]; - if (!!template.children) { + if (typeof template.children !== 'undefined') { const schema = await importSchema(template.name); if (schema.id === schemaURI) { return `${template.path.split('/').slice(-1)}/${schemaURI @@ -422,13 +422,13 @@ export const rangeToContainerClass = (Container, cls_key) => { .filter((v) => v.range === cls_key)[0].name; }; - /** +/** * Finds the slot names for a given class within the schema. * @param {Object} schema - The schema object containing "classes". * @param {string} class_name - The name of the class to search for slot names. * @returns {Array} An array of slot names. */ - export function findSlotNamesForClass(schema, class_name) { +export function findSlotNamesForClass(schema, class_name) { return Object.keys(schema.classes[class_name].slot_usage).map((field) => { return schema.classes[class_name].slot_usage[field].name; }); diff --git a/lib/utils/templates.js b/lib/utils/templates.js index 076856f9..98060e5a 100644 --- a/lib/utils/templates.js +++ b/lib/utils/templates.js @@ -14,7 +14,7 @@ export async function templatePathForSchemaURI(schemaURI) { // for now, this is just the manifest for (let i = 0; i < template_manifest.children.length; i++) { const template = template_manifest.children[i]; - if (!!template.children) { + if (typeof template.children !== 'undefined') { const schema = await importSchema(template.name); if (schema.id === schemaURI) { return `${template.path.split('/').slice(-1)}/${schemaURI @@ -422,13 +422,13 @@ export const rangeToContainerClass = (Container, cls_key) => { .filter((v) => v.range === cls_key)[0].name; }; - /** +/** * Finds the slot names for a given class within the schema. * @param {Object} schema - The schema object containing "classes". * @param {string} class_name - The name of the class to search for slot names. * @returns {Array} An array of slot names. */ - export function findSlotNamesForClass(schema, class_name) { +export function findSlotNamesForClass(schema, class_name) { return Object.keys(schema.classes[class_name].slot_usage).map((field) => { return schema.classes[class_name].slot_usage[field].name; }); diff --git a/web/index.js b/web/index.js index 4aa92764..9b374126 100644 --- a/web/index.js +++ b/web/index.js @@ -66,7 +66,6 @@ const main = async function () { context .reload(context.appConfig.template_path, { locale: 'en' }) .then(async (context) => { - // // internationalize // // TODO: connect to locale of browser! // // Takes `lang` as argument (unused)