From 4feecd4b8bd9fc1b6a3311728e6d19f7a33afe86 Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 13 Mar 2026 17:02:23 -0700 Subject: [PATCH 1/9] MVTC bug bash --- packages/components/package.json | 2 +- .../components/releaseNotes/components.md | 5 + .../components/editable/actions.test.ts | 198 +++++++++++++++++- .../internal/components/editable/actions.ts | 88 ++++++-- .../src/internal/util/utils.test.ts | 4 + .../components/src/internal/util/utils.ts | 14 ++ 6 files changed, 287 insertions(+), 24 deletions(-) diff --git a/packages/components/package.json b/packages/components/package.json index bd62ccf582..1715b2adec 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.23.1", + "version": "7.23.2-fb-mvtc-bash2.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 4321f482a8..ec95bdf963 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,11 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.X +*Released*: X March 2026 +- GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected +- GitHub Issue 942: Add error for duplicate values for MVTC fields + ### version 7.23.1 *Released*: 11 March 2026 - Merge from release26.3-SNAPSHOT to develop diff --git a/packages/components/src/internal/components/editable/actions.test.ts b/packages/components/src/internal/components/editable/actions.test.ts index 7fefffa7bc..b915020beb 100644 --- a/packages/components/src/internal/components/editable/actions.test.ts +++ b/packages/components/src/internal/components/editable/actions.test.ts @@ -859,7 +859,7 @@ describe('insertPastedData', () => { fieldKey: mvtc, jsonType: 'ARRAY', rangeURI: MULTI_CHOICE_TYPE.rangeURI, - validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de'], + validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de', 'A', 'B'], }), }, }); @@ -1175,6 +1175,202 @@ describe('insertPastedData', () => { expect(cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined(); expect(cellMessages.get(genCellKey(mvtc, 2))).toEqual({ message: 'Could not find "bad"' }); }); + + test('pasting string values with special characters, fromDragFill false', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(fkOne, 0), genCellKey(fkOne, 1), genCellKey(fkOne, 2)], + selectedColIdx: 0, + selectedRowIdx: 2, + }); + const changes = await validateAndInsertPastedData( + em, + 'hello world\n"hello, world"\n"say ""hello"""', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + // Space is preserved as-is + expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'hello world', raw: 'hello world' }])); + // Quoted comma: without fromDragFill, CSV quoting is NOT stripped + expect(cellValues.get(genCellKey(fkOne, 1))).toEqual( + List([{ display: '"hello, world"', raw: '"hello, world"' }]) + ); + // Escaped double quotes: without fromDragFill, CSV escaping is NOT processed + expect(cellValues.get(genCellKey(fkOne, 2))).toEqual( + List([{ display: '"say ""hello"""', raw: '"say ""hello"""' }]) + ); + }); + + test('drag fill string values with special characters, fromDragFill true', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [ + genCellKey(fkOne, 0), + genCellKey(fkOne, 1), + genCellKey(fkOne, 2), + genCellKey(fkOne, 3), + genCellKey(fkOne, 4), + genCellKey(fkOne, 5), + ], + selectedColIdx: 0, + selectedRowIdx: 5, + }); + const changes = await validateAndInsertPastedData( + em, + 'hello world\n"hello, world"\n"say ""hello"""', + undefined, + true, + true, + undefined, + false, + [[genCellKey(fkOne, 3), genCellKey(fkOne, 4), genCellKey(fkOne, 5)]], + true + ); + const cellValues = changes.cellValues; + // Original values unchanged + expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'qwer', raw: 'qwer' }])); + expect(cellValues.get(genCellKey(fkOne, 1))).toEqual(List([{ display: 'asdf', raw: 'asdf' }])); + expect(cellValues.get(genCellKey(fkOne, 2))).toEqual(List([{ display: 'zxcv', raw: 'zxcv' }])); + // Space: no CSV quoting to strip + expect(cellValues.get(genCellKey(fkOne, 3))).toEqual(List([{ display: 'hello world', raw: 'hello world' }])); + // Quoted comma: fromDragFill strips CSV quoting, comma preserved in value + expect(cellValues.get(genCellKey(fkOne, 4))).toEqual( + List([{ display: 'hello, world', raw: 'hello, world' }]) + ); + // Escaped double quotes: fromDragFill strips CSV quoting and unescapes "" + expect(cellValues.get(genCellKey(fkOne, 5))).toEqual( + List([{ display: 'say "hello"', raw: 'say "hello"' }]) + ); + }); + + test('pasting exactly A,B into mvtc matches single valid value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, 'A,B', undefined, true, true, undefined, true); + // 'A,B' exactly matches a validValue, treated as a single value (not split on comma) + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting A, B with space into mvtc parses as two values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, 'A, B', undefined, true, true, undefined, true); + // 'A, B' does not match any validValue, so parsed as CSV → ' B' and 'A' (sorted with leading space) + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual( + List([ + { display: 'B', raw: 'B' }, + { display: 'A', raw: 'A' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting quoted "A,B" into mvtc treats as single value', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A,B"', undefined, true, true, undefined, true); + // Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + }); + + test('pasting quoted "A, B" into mvtc, invalid', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0)], + selectedColIdx: 3, + selectedRowIdx: 0, + }); + const changes = await validateAndInsertPastedData(em, '"A, B"', undefined, true, true, undefined, true); + // Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value + expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A, B', raw: 'A, B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toEqual({ message: 'Could not find "A, B". Please make sure values that contain commas are properly quoted.' }); + }); + + test('pasting mvtc values combined with other valid values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1), genCellKey(mvtc, 2)], + selectedColIdx: 3, + selectedRowIdx: 2, + }); + const changes = await validateAndInsertPastedData( + em, + 'A,B\n"A,B",cc\nA,B,cc', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + // Row 0: 'A,B' exactly matches single validValue + expect(cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }])); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); + // Row 1: '"A,B",cc' → CSV parsed to ['A,B', 'cc'], both valid + expect(cellValues.get(genCellKey(mvtc, 1))).toEqual( + List([ + { display: 'A,B', raw: 'A,B' }, + { display: 'cc', raw: 'cc' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined(); + // Row 2: 'A,B,cc' without quotes → CSV parsed to ['A', 'B', 'cc'], all valid + expect(cellValues.get(genCellKey(mvtc, 2))).toEqual( + List([ + { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' }, + { display: 'cc', raw: 'cc' }, + ]) + ); + expect(changes.cellMessages.get(genCellKey(mvtc, 2))).toBeUndefined(); + }); + + test('pasting mvtc values combined with invalid values', async () => { + const em = baseEditorModel.applyChanges({ + selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1)], + selectedColIdx: 3, + selectedRowIdx: 1, + }); + const changes = await validateAndInsertPastedData( + em, + 'A,B,bad\n"A,B",bad', + undefined, + true, + true, + undefined, + true + ); + const cellValues = changes.cellValues; + const cellMessages = changes.cellMessages; + // Row 0: 'A,B,bad' → CSV parsed to ['A', 'B', 'bad'], 'bad' invalid + expect(cellValues.get(genCellKey(mvtc, 0))).toEqual( + List([ + { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' }, + { display: 'bad', raw: 'bad' }, + ]) + ); + expect(cellMessages.get(genCellKey(mvtc, 0))).toEqual({ message: 'Could not find "bad"' }); + // Row 1: '"A,B",bad' → CSV parsed to ['A,B', 'bad'], 'bad' invalid + expect(cellValues.get(genCellKey(mvtc, 1))).toEqual( + List([ + { display: 'A,B', raw: 'A,B' }, + { display: 'bad', raw: 'bad' }, + ]) + ); + expect(cellMessages.get(genCellKey(mvtc, 1))).toEqual({ message: 'Could not find "bad"' }); + }); }); describe('loadEditorModelData', () => { diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index ade88a24df..b6d55c64bb 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -1302,7 +1302,8 @@ export function dragFillEvent( forUpdate, targetContainerPath, false, - selectionToFill + selectionToFill, + true ); } @@ -1460,7 +1461,8 @@ async function insertPastedData( lockRowCount: boolean, forUpdate: boolean, targetContainerPath: string, - selectCells: boolean + selectCells: boolean, + fromDragFill?: boolean ): Promise> { const pastedData = paste.payload.data; let cellMessages = editorModel.cellMessages; @@ -1495,6 +1497,7 @@ async function insertPastedData( let pkValue = getPkValue(row, editorModel.queryInfo); if (!pkValue) pkValue = editorModel.getPkValue(rowIdx); + const isSingleColPaste = row.size === 1 && paste.payload.numCols === 1; for (let cn = 0; cn < row.size; cn++) { const val = row.get(cn); const colIdx = colMin + cn; @@ -1525,39 +1528,78 @@ async function insertPastedData( cv = valueDescriptors; msg = message; } else if (col?.isMultiChoice && Utils.isString(val)) { - const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); - const unmatched: string[] = []; const values = []; - parsedValues.forEach(v => { - const vt = v.trim(); - if (!vt) return; + let isSingleMatch = false; + if (isSingleColPaste) { + // GitHub Issue 916 + // if pasting into a single column, priotize matching the entire pasted value to a single valid value + const rawVal = val.trim(); + const vd = col.validValues?.find(d => d === rawVal); + if (vd) { + values.push({ display: rawVal, raw: rawVal }); + isSingleMatch = true; + } + } - const vd = col.validValues?.find(d => d === vt); - values.push({ display: vt, raw: vt }); + if (!isSingleMatch) { + const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); + const foundValues : string[] = []; - if (vd) return; + // GitHub Issue 942: Add error for duplicate values + const dupValues : string[] = []; + parsedValues.forEach(v => { + const vt = v.trim(); + if (!vt) return; - unmatched.push(vt); - }); + const vd = col.validValues?.find(d => d === vt); + values.push({ display: vt, raw: vt }); + + if (foundValues.indexOf(vt) > -1 && dupValues.indexOf(vt) === -1) { + dupValues.push(vt); + } else { + foundValues.push(vt); + } - if (unmatched.length) { - const valueStr = unmatched - .slice(0, 4) - .map(u => '"' + u + '"') - .join(', '); - msg = { message: lookupValidationErrorMessage(valueStr, true) }; + if (vd) return; + + unmatched.push(vt); + }); + + if (unmatched.length) { + const valueStr = unmatched + .slice(0, 4) + .map(u => '"' + u + '"') + .join(', '); + msg = { message: lookupValidationErrorMessage(valueStr, true) }; + } + else if (dupValues.length) { + const valueStr = dupValues + .slice(0, 4) + .map(u => '"' + u + '"') + .join(', '); + msg = { message: `Duplicate values not allowed: ${valueStr}.` }; + } } cv = List(values); } else { - const { message, value } = getValidatedEditableGridValue(val, col); + let valToValidate = val; + if (fromDragFill && Utils.isString(val)) { + // GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected + // drag fill always quoteValueWithDelimiters, needs to remove the extra quotes before validating + const parsedValues = parseCsvString(val, ',', true); + if (parsedValues.length === 1) + valToValidate = parsedValues[0].trim(); + } + + const { message, value } = getValidatedEditableGridValue(valToValidate, col); let display = value; // Issue 52326: Copy/paste of date values across cells changes date formats // Set display value to the pasted value, not the validated value, because for dates we use the JSON // format provided by LKS, which can include microseconds, and users probably didn't paste those. - if (col?.jsonType === 'date') display = val; + if (col?.jsonType === 'date') display = valToValidate; cv = List([{ display, raw: value }]); msg = message; @@ -1625,7 +1667,8 @@ export function validateAndInsertPastedData( forUpdate: boolean, targetContainerPath: string, selectCells: boolean, - selectionToFill?: string[][] + selectionToFill?: string[][], + fromDragFill?: boolean ): Promise> { let selectedColIdx: number; let selectedRowIdx: number; @@ -1663,7 +1706,8 @@ export function validateAndInsertPastedData( lockRowCount, forUpdate, targetContainerPath, - selectCells + selectCells, + fromDragFill ); } else { const fieldKey = editorModel.getFieldKeyByIndex(selectedColIdx); diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index ec4d3963e8..7f8de2e13b 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -1466,6 +1466,7 @@ describe('parseCsvString', () => { expect(parseCsvString('', '\t')).toStrictEqual([]); expect(parseCsvString('abcd', ' ')).toStrictEqual(['abcd']); expect(parseCsvString('a,b,c', ',')).toStrictEqual(['a', 'b', 'c']); + expect(parseCsvString('a, b,c', ',')).toStrictEqual(['a', ' b', 'c']); expect(parseCsvString(',b,c,', ',')).toStrictEqual(['', 'b', 'c']); expect(parseCsvString('a,,c', ',')).toStrictEqual(['a', '', 'c']); expect(parseCsvString('a\tb\tc', '\t')).toStrictEqual(['a', 'b', 'c']); @@ -1490,6 +1491,7 @@ describe('parseCsvString', () => { test('remove quotes', () => { expect(parseCsvString('a,"b","c,d"', ',', true)).toStrictEqual(['a', 'b', 'c,d']); + expect(parseCsvString('a, "b","c,d"', ',', true)).toStrictEqual(['a', 'b', 'c,d']); expect(parseCsvString(',"b","c,d"', ',', true)).toStrictEqual(['', 'b', 'c,d']); expect(parseCsvString('a,"b","c', ',', true)).toStrictEqual(['a', 'b', '"c']); expect(parseCsvString('a,"b",c"', ',', true)).toStrictEqual(['a', 'b', 'c"']); @@ -1503,6 +1505,8 @@ describe('parseCsvString', () => { expect(parseCsvString('"sam"', ',', true)).toStrictEqual(['sam']); expect(parseCsvString('"a""b"', ',', true)).toStrictEqual(['a"b']); expect(parseCsvString('"a"b"', ',', true)).toStrictEqual(['"a"b"']); + expect(parseCsvString('1,"2,3"', ',', true)).toStrictEqual(['1', '2,3']); + expect(parseCsvString('1, "2,3"', ',', true)).toStrictEqual(['1', '2,3']); }); }); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index f1efe6d3fd..a4e2ade472 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -661,6 +661,20 @@ export function parseCsvString(value: string, delimiter: string, removeQuotes?: while (start < value.length) { let end; const ch = value[start]; + // Tolerate a single space before a properly quoted value + if (ch === ' ' && start + 1 < value.length && value[start + 1] === '"') { + let testEnd = start + 1; + while (true) { + testEnd = value.indexOf('"', testEnd + 1); + if (testEnd === -1) break; + if (testEnd === value.length - 1 || value[testEnd + 1] !== '"') break; + testEnd++; + } + if (testEnd !== -1 && (testEnd === value.length - 1 || value.startsWith(delimiter, testEnd + 1))) { + start++; + continue; + } + } if (ch === delimiter) { // empty string case end = start; From 8dbc86a1aeb7245d09db3ef9b9f1652453beac77 Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 13 Mar 2026 17:06:23 -0700 Subject: [PATCH 2/9] MVTC bug bash --- packages/components/package-lock.json | 4 ++-- .../src/internal/components/editable/actions.test.ts | 12 +++++------- .../src/internal/components/editable/actions.ts | 12 +++++------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 860a8391ed..58df1baf09 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.23.1", + "version": "7.23.2-fb-mvtc-bash2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.23.1", + "version": "7.23.2-fb-mvtc-bash2.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/src/internal/components/editable/actions.test.ts b/packages/components/src/internal/components/editable/actions.test.ts index b915020beb..7e7e33b536 100644 --- a/packages/components/src/internal/components/editable/actions.test.ts +++ b/packages/components/src/internal/components/editable/actions.test.ts @@ -1236,13 +1236,9 @@ describe('insertPastedData', () => { // Space: no CSV quoting to strip expect(cellValues.get(genCellKey(fkOne, 3))).toEqual(List([{ display: 'hello world', raw: 'hello world' }])); // Quoted comma: fromDragFill strips CSV quoting, comma preserved in value - expect(cellValues.get(genCellKey(fkOne, 4))).toEqual( - List([{ display: 'hello, world', raw: 'hello, world' }]) - ); + expect(cellValues.get(genCellKey(fkOne, 4))).toEqual(List([{ display: 'hello, world', raw: 'hello, world' }])); // Escaped double quotes: fromDragFill strips CSV quoting and unescapes "" - expect(cellValues.get(genCellKey(fkOne, 5))).toEqual( - List([{ display: 'say "hello"', raw: 'say "hello"' }]) - ); + expect(cellValues.get(genCellKey(fkOne, 5))).toEqual(List([{ display: 'say "hello"', raw: 'say "hello"' }])); }); test('pasting exactly A,B into mvtc matches single valid value', async () => { @@ -1295,7 +1291,9 @@ describe('insertPastedData', () => { const changes = await validateAndInsertPastedData(em, '"A, B"', undefined, true, true, undefined, true); // Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A, B', raw: 'A, B' }])); - expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toEqual({ message: 'Could not find "A, B". Please make sure values that contain commas are properly quoted.' }); + expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toEqual({ + message: 'Could not find "A, B". Please make sure values that contain commas are properly quoted.', + }); }); test('pasting mvtc values combined with other valid values', async () => { diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index b6d55c64bb..7abae2911c 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -1545,10 +1545,10 @@ async function insertPastedData( if (!isSingleMatch) { const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); - const foundValues : string[] = []; + const foundValues: string[] = []; // GitHub Issue 942: Add error for duplicate values - const dupValues : string[] = []; + const dupValues: string[] = []; parsedValues.forEach(v => { const vt = v.trim(); if (!vt) return; @@ -1573,14 +1573,13 @@ async function insertPastedData( .map(u => '"' + u + '"') .join(', '); msg = { message: lookupValidationErrorMessage(valueStr, true) }; - } - else if (dupValues.length) { + } else if (dupValues.length) { const valueStr = dupValues .slice(0, 4) .map(u => '"' + u + '"') .join(', '); msg = { message: `Duplicate values not allowed: ${valueStr}.` }; - } + } } cv = List(values); } else { @@ -1589,8 +1588,7 @@ async function insertPastedData( // GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected // drag fill always quoteValueWithDelimiters, needs to remove the extra quotes before validating const parsedValues = parseCsvString(val, ',', true); - if (parsedValues.length === 1) - valToValidate = parsedValues[0].trim(); + if (parsedValues.length === 1) valToValidate = parsedValues[0].trim(); } const { message, value } = getValidatedEditableGridValue(valToValidate, col); From 46b7480a8ef381552ebcd56d66bfe71560227c1d Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 17 Mar 2026 09:55:29 -0700 Subject: [PATCH 3/9] Code review changes, designer checkbox UI --- .../components/releaseNotes/components.md | 2 + .../src/internal/OverlayTrigger.tsx | 5 +++ .../TextChoiceOptions.test.tsx | 2 - .../domainproperties/TextChoiceOptions.tsx | 42 ++++++++++--------- .../internal/components/editable/actions.ts | 20 ++++----- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index ec95bdf963..6bec29a992 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -5,6 +5,8 @@ Components, models, actions, and utility functions for LabKey applications and p *Released*: X March 2026 - GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected - GitHub Issue 942: Add error for duplicate values for MVTC fields +- GitHub Issue 961: Clicking the "Allow multiple selections" label doesn't toggle the checkbox +- GitHub Issue 932: No help text for disabled Multi-Value checkbox in designer. ### version 7.23.1 *Released*: 11 March 2026 diff --git a/packages/components/src/internal/OverlayTrigger.tsx b/packages/components/src/internal/OverlayTrigger.tsx index e5d11057a2..fa116f5142 100644 --- a/packages/components/src/internal/OverlayTrigger.tsx +++ b/packages/components/src/internal/OverlayTrigger.tsx @@ -129,6 +129,7 @@ interface Props extends PropsWithChildren { overlay: ReactElement; // See note in doc string below style?: CSSProperties; triggerType?: TriggerType; + noShow?: boolean; } /** @@ -155,6 +156,7 @@ export const OverlayTrigger: FC = ({ overlay, triggerType = 'hover', style, + noShow, }) => { const id_ = useMemo(() => id ?? generateId(), [id]); const { onMouseEnter, onMouseLeave, onClick, portalEl, show, targetRef } = useOverlayTriggerState( @@ -167,6 +169,9 @@ export const OverlayTrigger: FC = ({ const className_ = classNames('overlay-trigger', className); const clonedContent = cloneElement(overlay, { targetRef }); + if (noShow) + return children; + return (
{ const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement; expect(multiCheckbox).toBeInTheDocument(); expect(multiCheckbox).toBeDisabled(); - const labelSpan = screen.getByText('Allow multiple selections'); - expect(labelSpan.getAttribute('title')).toBe('Multiple values are currently used by at least one data row.'); }); test('multi-choice checkbox not present', () => { diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index dbc05aee17..5f5feca196 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -27,6 +27,8 @@ import { getTextChoiceInUseValues, TextChoiceInUseValues } from './actions'; import { createFormInputId } from './utils'; import { isFieldFullyLocked } from './propertiesUtil'; import { MULTI_CHOICE_TYPE, TEXT_CHOICE_TYPE } from './PropDescType'; +import { Popover } from '../../Popover'; +import { OverlayTrigger } from '../../OverlayTrigger'; const MIN_VALUES_FOR_SEARCH_COUNT = 2; const HELP_TIP_BODY =

The set of values to be used as drop-down options to restrict data entry into this field.

; @@ -100,6 +102,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => { const [showAddValuesModal, setShowAddValuesModal] = useState(); const [search, setSearch] = useState(''); const fieldTypeId = createFormInputId(DOMAIN_FIELD_TYPE, domainIndex, index); + const mvPopOverId = useMemo(() => createFormInputId('mv-in-use-popover', domainIndex, index), [domainIndex, index]); const isMultiChoiceField = field.dataType.name === MULTI_CHOICE_TYPE.name; // keep a map from the updated values for the in-use field values to their original values @@ -280,25 +283,26 @@ export const TextChoiceOptionsImpl: FC = memo(props => { title={`Add Values (max ${maxValueCount})`} /> {allowMultiChoice && ( - <> - - - Allow multiple selections - - + + Multiple values are currently used by at least one data row. + + } + > + + )}
diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index 7abae2911c..94985a8c87 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -1529,12 +1529,12 @@ async function insertPastedData( msg = message; } else if (col?.isMultiChoice && Utils.isString(val)) { const unmatched: string[] = []; - const values = []; + const values: ValueDescriptor[] = []; let isSingleMatch = false; if (isSingleColPaste) { // GitHub Issue 916 - // if pasting into a single column, priotize matching the entire pasted value to a single valid value + // if pasting into a single column, prioritize matching the entire pasted value to a single valid value const rawVal = val.trim(); const vd = col.validValues?.find(d => d === rawVal); if (vd) { @@ -1545,23 +1545,23 @@ async function insertPastedData( if (!isSingleMatch) { const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); - const foundValues: string[] = []; + const foundValues = new Set(); // GitHub Issue 942: Add error for duplicate values - const dupValues: string[] = []; + const dupValues = new Set(); parsedValues.forEach(v => { const vt = v.trim(); if (!vt) return; - const vd = col.validValues?.find(d => d === vt); values.push({ display: vt, raw: vt }); - if (foundValues.indexOf(vt) > -1 && dupValues.indexOf(vt) === -1) { - dupValues.push(vt); + if (foundValues.has(vt)) { + dupValues.add(vt); } else { - foundValues.push(vt); + foundValues.add(vt); } + const vd = col.validValues?.find(d => d === vt); if (vd) return; unmatched.push(vt); @@ -1573,8 +1573,8 @@ async function insertPastedData( .map(u => '"' + u + '"') .join(', '); msg = { message: lookupValidationErrorMessage(valueStr, true) }; - } else if (dupValues.length) { - const valueStr = dupValues + } else if (dupValues.size > 0) { + const valueStr = Array.from(dupValues) .slice(0, 4) .map(u => '"' + u + '"') .join(', '); From 655539c15516115773682c9f62158aabd50a98aa Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 17 Mar 2026 09:58:39 -0700 Subject: [PATCH 4/9] Code review changes, designer checkbox UI --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 58df1baf09..c3fb98decb 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.1", + "version": "7.23.2-fb-mvtc-bash2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.1", + "version": "7.23.2-fb-mvtc-bash2.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 1715b2adec..e9d7a6a75e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.1", + "version": "7.23.2-fb-mvtc-bash2.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 45525af5ff2b8b2b6f9dcd4905cf833787f3f366 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 17 Mar 2026 20:19:27 -0700 Subject: [PATCH 5/9] GitHub Issue 917: Copying/pasting in the grid doesn't reorder selection --- packages/components/package-lock.json | 4 +-- packages/components/package.json | 2 +- .../components/releaseNotes/components.md | 3 +- .../components/editable/actions.test.ts | 4 +-- .../internal/components/editable/actions.ts | 2 +- .../src/internal/util/utils.test.ts | 31 +++++++++++++++++++ .../components/src/internal/util/utils.ts | 7 ++++- 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index c3fb98decb..c3b86b8562 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.2", + "version": "7.23.2-fb-mvtc-bash2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.2", + "version": "7.23.2-fb-mvtc-bash2.3", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index e9d7a6a75e..8fed171958 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.23.2-fb-mvtc-bash2.2", + "version": "7.23.2-fb-mvtc-bash2.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 6bec29a992..5b29a81a57 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -6,7 +6,8 @@ Components, models, actions, and utility functions for LabKey applications and p - GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected - GitHub Issue 942: Add error for duplicate values for MVTC fields - GitHub Issue 961: Clicking the "Allow multiple selections" label doesn't toggle the checkbox -- GitHub Issue 932: No help text for disabled Multi-Value checkbox in designer. +- GitHub Issue 932: No help text for disabled Multi-Value checkbox in designer +- GitHub Issue 917: Copying/pasting in the grid doesn't reorder selection ### version 7.23.1 *Released*: 11 March 2026 diff --git a/packages/components/src/internal/components/editable/actions.test.ts b/packages/components/src/internal/components/editable/actions.test.ts index 7e7e33b536..2fd0a332d4 100644 --- a/packages/components/src/internal/components/editable/actions.test.ts +++ b/packages/components/src/internal/components/editable/actions.test.ts @@ -1263,8 +1263,8 @@ describe('insertPastedData', () => { // 'A, B' does not match any validValue, so parsed as CSV → ' B' and 'A' (sorted with leading space) expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual( List([ - { display: 'B', raw: 'B' }, { display: 'A', raw: 'A' }, + { display: 'B', raw: 'B' } ]) ); expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined(); @@ -1342,7 +1342,7 @@ describe('insertPastedData', () => { }); const changes = await validateAndInsertPastedData( em, - 'A,B,bad\n"A,B",bad', + 'A, B, bad\n"A,B",bad', undefined, true, true, diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index 94985a8c87..3216da2f95 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -1544,7 +1544,7 @@ async function insertPastedData( } if (!isSingleMatch) { - const parsedValues = parseCsvString(val, ',', true).sort(caseSensitiveNaturalSort); + const parsedValues = parseCsvString(val, ',', true, true /*GitHub Issue 917*/).sort(caseSensitiveNaturalSort); const foundValues = new Set(); // GitHub Issue 942: Add error for duplicate values diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 7f8de2e13b..acc108faa0 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -1508,6 +1508,37 @@ describe('parseCsvString', () => { expect(parseCsvString('1,"2,3"', ',', true)).toStrictEqual(['1', '2,3']); expect(parseCsvString('1, "2,3"', ',', true)).toStrictEqual(['1', '2,3']); }); + + test('trim space', () => { + // trimSpace should remove leading/trailing whitespace from each parsed field + expect(parseCsvString('a, b,c', ',', false, true)).toStrictEqual(['a', 'b', 'c']); + // with quoted values and removeQuotes=true, inner spaces around the quoted content should be trimmed as well + expect(parseCsvString('a, "b", c', ',', true, true)).toStrictEqual(['a', 'b', 'c']); + + // trimSpace without removeQuotes — whitespace outside quotes is trimmed but quotes are preserved + expect(parseCsvString(' a , b , c ', ',', false, true)).toStrictEqual(['a', 'b', 'c']); + expect(parseCsvString('a, "b" ,c', ',', false, true)).toStrictEqual(['a', '"b"', 'c']); + + // trimSpace with tabs and mixed whitespace + expect(parseCsvString(' a ,\tb , c\t', ',', false, true)).toStrictEqual(['a', 'b', 'c']); + + // trimSpace with empty fields — empty strings remain empty after trim + expect(parseCsvString(' , , ', ',', false, true)).toStrictEqual(['', '', '']); + expect(parseCsvString(', ,', ',', false, true)).toStrictEqual(['', '']); + + // trimSpace with single value + expect(parseCsvString(' hello ', ',', false, true)).toStrictEqual(['hello']); + + // trimSpace with tab delimiter + expect(parseCsvString(' a \t b \t c ', '\t', false, true)).toStrictEqual(['a', 'b', 'c']); + + // trimSpace with semicolon delimiter + expect(parseCsvString(' x ; y ; z ', ';', false, true)).toStrictEqual(['x', 'y', 'z']); + + // trimSpace=false should NOT trim (verify trimSpace is actually doing the work) + expect(parseCsvString(' a , b , c ', ',', false, false)).toStrictEqual([' a ', ' b ', ' c ']); + expect(parseCsvString(' a , b , c ', ',')).toStrictEqual([' a ', ' b ', ' c ']); + }); }); describe('quoteValueWithDelimiters', () => { diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index a4e2ade472..e03f2a75b9 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -649,7 +649,7 @@ export const handleFileInputChange = ( }; }; -export function parseCsvString(value: string, delimiter: string, removeQuotes?: boolean): string[] { +export function parseCsvString(value: string, delimiter: string, removeQuotes?: boolean, trimSpace?: boolean): string[] { if (delimiter === '"') throw 'Unsupported delimiter: ' + delimiter; if (!delimiter) return undefined; @@ -722,6 +722,11 @@ export function parseCsvString(value: string, delimiter: string, removeQuotes?: } start = end + delimiter.length; } + if (trimSpace) { + for (let i = 0; i < parsedValues.length; i++) { + parsedValues[i] = parsedValues[i].trim(); + } + } return parsedValues; } From 500c743497574fa37953ab248b56c1236db5a6e0 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 18 Mar 2026 09:54:52 -0700 Subject: [PATCH 6/9] comment --- packages/components/src/internal/util/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index e03f2a75b9..58a51829a3 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -662,6 +662,7 @@ export function parseCsvString(value: string, delimiter: string, removeQuotes?: let end; const ch = value[start]; // Tolerate a single space before a properly quoted value + // TODO: tolerate space after quote, also multiple spaces: expect(parseCsvString('1, "2,3" , 4', ',', true)).toStrictEqual(['1', '2,3', ' 4']); if (ch === ' ' && start + 1 < value.length && value[start + 1] === '"') { let testEnd = start + 1; while (true) { From b3f20023b47cb41165326e64d380ec01dd8a6b84 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 19 Mar 2026 20:18:01 -0700 Subject: [PATCH 7/9] revert editable grid paste related changes --- .../components/releaseNotes/components.md | 2 - .../domainproperties/TextChoiceOptions.tsx | 2 +- .../components/editable/actions.test.ts | 196 +----------------- .../internal/components/editable/actions.ts | 103 ++++----- .../src/internal/util/utils.test.ts | 35 ---- .../components/src/internal/util/utils.ts | 22 +- 6 files changed, 41 insertions(+), 319 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 9e5d2efdc2..c2f4d9cd99 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -3,11 +3,9 @@ Components, models, actions, and utility functions for LabKey applications and p ### version 7.X *Released*: X March 2026 -- GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected - GitHub Issue 942: Add error for duplicate values for MVTC fields - GitHub Issue 961: Clicking the "Allow multiple selections" label doesn't toggle the checkbox - GitHub Issue 932: No help text for disabled Multi-Value checkbox in designer -- GitHub Issue 917: Copying/pasting in the grid doesn't reorder selection ### version 7.23.2 *Released*: 18 March 2026 diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index 5f5feca196..6743d26bf8 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -291,7 +291,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => { } > -