Skip to content

Commit 43e16a2

Browse files
committed
translation form fixes and new styling
1 parent 0e90544 commit 43e16a2

1 file changed

Lines changed: 147 additions & 54 deletions

File tree

lib/SchemaEditor.js

Lines changed: 147 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ export class SchemaEditor {
293293
// row cell metadata. Issue: if a schema has lost focus, and instead
294294
// all schemas are selected ...
295295
const schema_row = schema.current_selection[0];
296-
if (schema_row < 0) {
296+
if (schema_row == null || schema_row < 0) {
297297
alert("In order to see the translation form, first select a row with a schema that has locales.")
298298
return false;
299299
}
@@ -311,6 +311,7 @@ export class SchemaEditor {
311311

312312
// Translation table form for all selected rows.
313313
const [schema_part, key_name, sub_part, sub_part_key_name, text_columns] = this.TRANSLATABLE[dh.template_name];
314+
const colPct = Math.floor(100 / text_columns.length);
314315

315316
let translate_rows = '';
316317

@@ -378,7 +379,7 @@ export class SchemaEditor {
378379
value = value.join(';')
379380
}
380381

381-
translate_cells += `<td><textarea name="${column_name}" data-path="${path}">${value}</textarea></td>`;
382+
translate_cells += `<td style="width:${colPct}%;max-width:${colPct}%"><textarea name="${column_name}" data-path="${path}">${value}</textarea></td>`;
382383
}
383384
// Because origin is different, we can't bring google
384385
// translate results directly into an iframe.
@@ -394,7 +395,7 @@ export class SchemaEditor {
394395
<thead>
395396
<tr>
396397
<th class="locale">locale</th>
397-
<th>${this.TRANSLATABLE[dh.template_name][4].join('</th><th>')}</th>
398+
${text_columns.map(col => '<th style="width:' + colPct + '%;max-width:' + colPct + '%">' + col + '</th>').join('')}
398399
<th>translate</th>
399400
</tr>
400401
</thead>
@@ -510,6 +511,83 @@ export class SchemaEditor {
510511
//initSchemaTab (dh, hot_settings) {}
511512
//initSlotGroupTab (dh, hot_settings) {}
512513

514+
/**
515+
* Build (or rebuild) an index of schema-defined slot rows for the Slot tab.
516+
* The index maps `${schema_id}\0${slot_name}` → physical row number for every
517+
* row whose slot_type is 'slot'. Used by the cells() callback so it can look
518+
* up inherited values in O(1) instead of walking adjacent visual rows (which
519+
* broke under sorting, drag-and-drop, or row deletion).
520+
*/
521+
buildSlotDefinitionIndex(dh) {
522+
const index = new Map();
523+
const sourceData = dh.hot.getSourceData();
524+
if (!sourceData) return;
525+
for (let physRow = 0; physRow < sourceData.length; physRow++) {
526+
const slot_type = dh.hot.getSourceDataAtCell(physRow, dh.slot_type_column);
527+
if (slot_type === 'slot') {
528+
const schema = dh.hot.getSourceDataAtCell(physRow, dh.schema_name_column);
529+
const name = dh.hot.getSourceDataAtCell(physRow, dh.slot_name_column);
530+
if (schema != null && schema !== '' && name != null && name !== '') {
531+
index.set(`${schema}\0${name}`, physRow);
532+
}
533+
}
534+
}
535+
dh.slotDefinitionIndex = index;
536+
}
537+
538+
/**
539+
* Post-filter validation errors for the Slot tab.
540+
* Removes errors on empty cells of slot_usage rows where the corresponding
541+
* schema-defined slot row (same schema_id + slot_name) has a non-empty value
542+
* for that column. Those cells are intentionally blank — their value is
543+
* supplied by LinkML inheritance — so flagging them as required-but-empty
544+
* would be a false positive. Cells in slot/attribute rows, and non-empty
545+
* cells in slot_usage rows, are left untouched.
546+
*
547+
* @param {Object} dh DataHarmonizer instance for the Slot tab.
548+
* @param {Array} data Visual-row data array from hot.getData().
549+
* @param {Object} errors Raw error map {visualRow: {col: message}}.
550+
* @returns {Object} Filtered error map.
551+
*/
552+
filterInheritedSlotUsageErrors(dh, data, errors) {
553+
if (!dh.slotDefinitionIndex || dh.slotDefinitionIndex.size === 0) return errors;
554+
555+
const filtered = {};
556+
for (const [vRowStr, colErrors] of Object.entries(errors)) {
557+
const vRow = Number(vRowStr);
558+
const slotType = data[vRow]?.[dh.slot_type_column];
559+
560+
if (slotType !== 'slot_usage') {
561+
filtered[vRowStr] = colErrors;
562+
continue;
563+
}
564+
565+
// For slot_usage rows keep only errors on cells that are not inherited.
566+
const slotName = data[vRow][dh.slot_name_column];
567+
const schemaName = data[vRow][dh.schema_name_column];
568+
const defPhysRow = dh.slotDefinitionIndex.get(`${schemaName}\0${slotName}`);
569+
570+
const keptErrors = {};
571+
for (const [colStr, message] of Object.entries(colErrors)) {
572+
const col = Number(colStr);
573+
const cellValue = data[vRow][col];
574+
const isEmpty = cellValue === null || cellValue === undefined || cellValue === '';
575+
576+
if (isEmpty && defPhysRow !== undefined) {
577+
const defValue = dh.hot.getSourceDataAtCell(defPhysRow, col);
578+
const defHasValue = defValue !== null && defValue !== undefined && defValue !== '';
579+
if (defHasValue) continue; // inherited — suppress error
580+
}
581+
keptErrors[colStr] = message;
582+
}
583+
584+
if (Object.keys(keptErrors).length > 0) {
585+
filtered[vRowStr] = keptErrors;
586+
}
587+
}
588+
return filtered;
589+
}
590+
513591
initSlotTab (dh, hot_settings) {
514592

515593
const slot_table_attribute_column = ['rank','inlined','inlined_as_list'].map((x) => dh.slot_name_to_column[x]);
@@ -526,31 +604,62 @@ export class SchemaEditor {
526604

527605
hot_settings.fixedColumnsLeft = 4; // Freeze both schema and slot name.
528606

607+
// Override getInvalidCells so that empty inherited cells in slot_usage rows
608+
// are not reported as validation errors. The standard validator sees them
609+
// as required-but-empty, but they are intentionally blank (values are
610+
// supplied by downstream LinkML inheritance from the parent slot definition).
611+
const originalGetInvalidCells = dh.getInvalidCells.bind(dh);
612+
dh.getInvalidCells = (data) => {
613+
const errors = originalGetInvalidCells(data);
614+
return this.filterInheritedSlotUsageErrors(dh, data, errors);
615+
};
616+
617+
// Maintain the slot-definition index so cells() can do O(1) lookups.
618+
// indexDirty starts true so the very first render always attempts a build.
619+
// beforeRender fires just before HOT calls cells() for each visible cell.
620+
// We stay dirty until a render sees actual source rows, because in HOT 15
621+
// afterLoadData fires AFTER the render cycle that follows updateSettings({data}),
622+
// so we cannot rely on afterLoadData to mark dirty before the first real render.
623+
let indexDirty = true;
624+
const markDirty = () => { indexDirty = true; };
625+
dh.hot.addHook('afterChange', (changes) => { if (changes) markDirty(); });
626+
dh.hot.addHook('afterRemoveRow', markDirty);
627+
dh.hot.addHook('afterRowMove', markDirty);
628+
dh.hot.addHook('beforeRender', () => {
629+
if (indexDirty) {
630+
this.buildSlotDefinitionIndex(dh);
631+
// Only mark clean once real data is present. If HOT hasn't loaded its
632+
// rows yet the rebuilt index will be empty and we retry next render.
633+
const sourceData = dh.hot.getSourceData();
634+
if (sourceData && sourceData.length > 0) {
635+
indexDirty = false;
636+
}
637+
}
638+
});
639+
529640
// function(row, col, prop) has prop == column name if implemented; otherwise = col #
530641
// In some report views certain kinds of row are readOnly, e.g. Schema
531642
// Editor schema slots if looking at a class's slot_usage slots.
532643
// Issue: https://forum.handsontable.com/t/gh-6274-best-place-to-set-cell-meta-data/4710
533644
// We can't lookup existing .getCellMeta() without causing stack overflow.
534-
// ISSUE: We have to disable sorting for 'Slot' table because
645+
// ISSUE: We have to disable sorting for 'Slot' table because
535646
// separate reporting controls are at work.
536647

537-
// ASSUMES VISUAL alphabetical order with schema fields at top
538648
const slot_editable_keys = [dh.slot_type_column, dh.slot_class_name_column, dh.slot_name_column];
539649

540650
// ISSUE: user clicking on "toggle expert user mode" doesn't visually
541-
// take effect until after dh.render(), so cellProp.readOnly doesn't
651+
// take effect until after dh.render(), so cellProp.readOnly doesn't
542652
// work right away.
543-
// NOTE: cells() receives visual row indices; toPhysicalRow() maps to source data.
544-
hot_settings.cells = function(row, col) {
653+
// NOTE: In HOT 15+, cells() receives PHYSICAL row and column indices
654+
// (see dynamicCellMeta.js: cellMeta.cells(physicalRow, physicalColumn, prop)).
655+
// All lookups below use getSourceDataAtCell with the physical row directly.
656+
hot_settings.cells = function(row, col) {
545657
let cellProp = {};
546658
let read_only = false;
547659

548-
// cells() receives a visual row index; convert to physical for source data access.
549-
const physicalRow = dh.hot.toPhysicalRow(row);
550-
// slot, slot_usage, attribute
551-
let slot_type = dh.hot.getSourceDataAtCell(physicalRow, dh.slot_type_column);
660+
// row is already a physical row index in HOT 15+.
661+
const slot_type = dh.hot.getSourceDataAtCell(row, dh.slot_type_column);
552662
cellProp.className = 'tabFieldTd ' + slot_type;
553-
let visual_row = row;
554663

555664
if (col in [dh.schema_name_column]) { // 0th column usually.
556665
read_only = true;
@@ -564,29 +673,21 @@ export class SchemaEditor {
564673
if (slot_editable_keys.includes(col)) {
565674
read_only = false;
566675
}
567-
568-
// INHERIT read-only from slot fields.
569-
// If previous row has type 'slot' and same 'name'
570-
else
571-
if (read_only === false) {
572-
// Source data or visual data?
573-
let found = false;
574-
const this_slot_name = dh.hot.getDataAtCell(visual_row, dh.slot_name_column);
575-
const this_schema = dh.hot.getDataAtCell(visual_row, dh.schema_name_column);
576-
while (visual_row > 0 && this_slot_name === dh.hot.getDataAtCell(visual_row-1, dh.slot_name_column)
577-
&& this_schema === dh.hot.getDataAtCell(visual_row-1, dh.schema_name_column)) {
578-
visual_row += -1;
579-
found = true;
580-
}
581-
if (found) {
582-
const prev_slot_type = dh.hot.getDataAtCell(visual_row, dh.slot_type_column);
583-
const prev_value = dh.hot.getDataAtCell(visual_row, col);
584-
if (prev_value && prev_slot_type === 'slot') {
585-
read_only = true;
586-
cellProp.className += ' inherited'
587-
}
588-
}
589-
}
676+
// Inherit read-only from the schema-defined slot, if one exists.
677+
// Look it up by (schema_id, slot_name) in the pre-built index so
678+
// this is O(1) and unaffected by sort order or row position.
679+
else if (read_only === false && dh.slotDefinitionIndex) {
680+
const this_slot_name = dh.hot.getSourceDataAtCell(row, dh.slot_name_column);
681+
const this_schema = dh.hot.getSourceDataAtCell(row, dh.schema_name_column);
682+
const defPhysRow = dh.slotDefinitionIndex.get(`${this_schema}\0${this_slot_name}`);
683+
if (defPhysRow !== undefined) {
684+
const def_value = dh.hot.getSourceDataAtCell(defPhysRow, col);
685+
if (def_value !== null && def_value !== undefined && def_value !== '') {
686+
read_only = true;
687+
cellProp.className += ' inherited';
688+
}
689+
}
690+
}
590691
}
591692

592693
/* Handsontable assigns .htDimmed to any cell with .readOnly = true
@@ -1427,7 +1528,7 @@ export class SchemaEditor {
14271528
version: value.version,
14281529
class_uri: value.class_uri,
14291530
is_a: value.is_a,
1430-
tree_root: this.getBoolean(value.tree_root), // Not needed?
1531+
tree_root: value.tree_root ?? null,
14311532
see_also: this.getDelimitedString(value.see_also)
14321533
});
14331534

@@ -1584,19 +1685,19 @@ export class SchemaEditor {
15841685
class_id: class_name,
15851686
rank: slot_obj.rank,
15861687
slot_group: slot_obj.slot_group || '',
1587-
inlined: this.getBoolean(slot_obj.inlined),
1588-
inlined_as_list: this.getBoolean(slot_obj.inlined_as_list),
1688+
inlined: slot_obj.inlined ?? null,
1689+
inlined_as_list: slot_obj.inlined_as_list ?? null,
15891690

15901691
slot_uri: slot_obj.slot_uri,
15911692
title: slot_obj.title,
15921693
range: slot_obj.range || this.getDelimitedString(slot_obj.any_of, 'range'),
15931694
unit: slot_obj.unit?.ucum_code || '', // See https://linkml.io/linkml-model/latest/docs/UnitOfMeasure/
1594-
required: this.getBoolean(slot_obj.required),
1595-
recommended: this.getBoolean(slot_obj.recommended),
1695+
required: slot_obj.required ?? null,
1696+
recommended: slot_obj.recommended ?? null,
15961697
description: slot_obj.description,
15971698
aliases: slot_obj.aliases,
1598-
identifier: this.getBoolean(slot_obj.identifier),
1599-
multivalued: this.getBoolean(slot_obj.multivalued),
1699+
identifier: slot_obj.identifier ?? null,
1700+
multivalued: slot_obj.multivalued ?? null,
16001701
minimum_value: slot_obj.minimum_value,
16011702
maximum_value: slot_obj.maximum_value,
16021703
minimum_cardinality: slot_obj.minimum_cardinality,
@@ -1640,18 +1741,10 @@ export class SchemaEditor {
16401741
}
16411742

16421743

1643-
/** Incomming data has booleans as json true/false; convert to handsontable TRUE / FALSE
1644-
* Return string so validation works on that (validateValAgainstVocab() where picklist
1645-
* is boolean)
1646-
*/
1647-
getBoolean(value) {
1648-
if (value === undefined)
1649-
return value; // Allow default / empty-value to be passed along.
1650-
return(!!value).toString().toUpperCase();
1651-
};
1652-
16531744
setToBoolean(value) {
1654-
return value?.toLowerCase?.() === 'true';
1745+
if (typeof value === 'boolean') return value;
1746+
if (value === null || value === undefined) return false;
1747+
return value.toLowerCase() === 'true';
16551748
}
16561749

16571750
deleteRowsByKeys(class_name, keys) {

0 commit comments

Comments
 (0)