@@ -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