Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "7.24.0",
"version": "7.24.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
6 changes: 6 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 7.24.1
*Released*: 25 March 2026
- Factor `EditingForm` out of `EditableDetailPanel` and load model with update columns
- Update `extractChanges()` to account for `column.jsonType === 'array'`
- Refactor `arrayEquals` to fix edge cases of array mutation and delimiter collisions

### version 7.24.0
*Released*: 24 March 2026
- SchemaQuery.isEqual: add optional includeViewName argument, defaults to true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fromJS } from 'immutable';
import { fromJS, List } from 'immutable';

import { QueryColumn } from '../../../../public/QueryColumn';
import { QueryInfo } from '../../../../public/QueryInfo';
Expand Down Expand Up @@ -26,47 +26,55 @@ const COLUMN_FILE_INPUT = new QueryColumn({
inputType: 'file',
jsonType: 'string',
});
const COLUMN_ARRAY_INPUT = new QueryColumn({
fieldKey: 'arrInput',
name: 'arrInput',
fieldKeyArray: ['arrInput'],
inputType: 'text',
jsonType: 'array',
});
const QUERY_INFO = QueryInfo.fromJsonForTests({
name: 'test',
schemaName: 'schema',
columns: {
strInput: COLUMN_STRING_INPUT,
arrInput: COLUMN_ARRAY_INPUT,
dtInput: COLUMN_DATE_INPUT,
fileInput: COLUMN_FILE_INPUT,
strInput: COLUMN_STRING_INPUT,
},
});

describe('extractChanges', () => {
test('file input', () => {
const FILE = new File([], 'file');
const currentData = fromJS({ fileInput: { value: FILE } });
expect(extractChanges(QUERY_INFO, currentData, {}).fileInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, {}).fileInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { fileInput: undefined }).fileInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { fileInput: FILE }).fileInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { fileInput: null }).fileInput).toBe(null);
expect(extractChanges(QUERY_INFO, currentData, { fileInput: null }).fileInput).toBeNull();
expect(
extractChanges(QUERY_INFO, currentData, { fileInput: new File([], 'fileEdit') }).fileInput
).toBeDefined();
});

test('string input', () => {
const currentData = fromJS({ strInput: { value: 'abc' } });
expect(extractChanges(QUERY_INFO, currentData, {}).strInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, { strInput: undefined }).strInput).toBe(null);
expect(extractChanges(QUERY_INFO, currentData, { strInput: null }).strInput).toBe(null);
expect(extractChanges(QUERY_INFO, currentData, {}).strInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { strInput: undefined }).strInput).toBeNull();
expect(extractChanges(QUERY_INFO, currentData, { strInput: null }).strInput).toBeNull();
expect(extractChanges(QUERY_INFO, currentData, { strInput: '' }).strInput).toBe('');
expect(extractChanges(QUERY_INFO, currentData, { strInput: [] }).strInput).toStrictEqual([]);
expect(extractChanges(QUERY_INFO, currentData, { strInput: 'abc' }).strInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, { strInput: ' abc ' }).strInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, { strInput: 'abc' }).strInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { strInput: ' abc ' }).strInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { strInput: ' abcd ' }).strInput).toBe('abcd');
});

test('date input', () => {
let currentData = fromJS({ dtInput: { value: '2022-08-30 01:02:03' } });
expect(extractChanges(QUERY_INFO, currentData, {}).dtInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, { dtInput: undefined }).dtInput).toBe(null);
expect(extractChanges(QUERY_INFO, currentData, { dtInput: null }).dtInput).toBe(null);
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30 01:02:03' }).dtInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, {}).dtInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { dtInput: undefined }).dtInput).toBeNull();
expect(extractChanges(QUERY_INFO, currentData, { dtInput: null }).dtInput).toBeNull();
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30 01:02:03' }).dtInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30 01:02:04' }).dtInput).toBe(
'2022-08-30 01:02:04'
); // Issue 40139, 52536: date comparison only down to minute precision
Expand All @@ -81,10 +89,27 @@ describe('extractChanges', () => {
);

currentData = fromJS({ dtInput: { value: '2022-08-30' } });
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30' }).dtInput).toBe(undefined);
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30' }).dtInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-31' }).dtInput).toBe('2022-08-31');
expect(extractChanges(QUERY_INFO, currentData, { dtInput: '2022-08-30 01:02:03' }).dtInput).toBe(
'2022-08-30 01:02:03'
);
});

test('array input', () => {
// The existing value is an Immutable List
const currentDataList = fromJS({ arrInput: { value: List([1, 2, 3]) } });
expect(extractChanges(QUERY_INFO, currentDataList, { arrInput: [1, 2, 3] }).arrInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentDataList, { arrInput: [1, 2, 4] }).arrInput).toEqual([1, 2, 4]);
expect(extractChanges(QUERY_INFO, currentDataList, { arrInput: [1, 2] }).arrInput).toEqual([1, 2]);

// Existing value is a raw JavaScript array
const currentDataRaw = fromJS({ arrInput: { value: [10, 20] } });
expect(extractChanges(QUERY_INFO, currentDataRaw, { arrInput: [10, 20] }).arrInput).toBeUndefined();
expect(extractChanges(QUERY_INFO, currentDataRaw, { arrInput: [10, 20, 30] }).arrInput).toEqual([10, 20, 30]);

// Nulls and Undefined
expect(extractChanges(QUERY_INFO, currentDataList, { arrInput: null }).arrInput).toBeNull();
expect(extractChanges(QUERY_INFO, currentDataList, { arrInput: undefined }).arrInput).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { List, Map } from 'immutable';
import { Utils } from '@labkey/api';

import { QueryInfo } from '../../../../public/QueryInfo';
import { isSetEqual } from '../../../util/utils';

function arrayListIsEqual(valueArr: Array<string | number>, nestedModelList: List<Map<string, any>>): boolean {
let matched = 0;
Expand Down Expand Up @@ -92,6 +93,14 @@ export function extractChanges(
if (existingValue === newValue) {
return false;
}
} else if (column?.jsonType === 'array') {
if (Array.isArray(newValue)) {
const existingArray = List.isList(existingValue) ? existingValue.toJS() : existingValue;

if (Array.isArray(existingArray) && isSetEqual(newValue, existingArray)) {
return false;
}
}
}

changedValues[col.name] = newValue === undefined ? null : newValue;
Expand Down
40 changes: 32 additions & 8 deletions packages/components/src/internal/util/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,7 @@ describe('arrayEquals', () => {
test('ignore order, case sensitive', () => {
expect(arrayEquals(undefined, undefined)).toBeTruthy();
expect(arrayEquals(undefined, null)).toBeTruthy();
expect(arrayEquals(null, undefined)).toBeTruthy();
expect(arrayEquals([], [])).toBeTruthy();
expect(arrayEquals(null, [])).toBeFalsy();
expect(arrayEquals(['a'], null)).toBeFalsy();
Expand Down Expand Up @@ -1621,38 +1622,61 @@ describe('arrayEquals', () => {
expect(arrayEquals(['a', 'b'], ['A', 'b'], false)).toBeFalsy();
expect(arrayEquals(['a', 'b'], ['B', 'A'], false)).toBeFalsy();
});

test('does not mutate original arrays', () => {
const arrA = ['b', 'a'];
const arrB = ['a', 'b'];
arrayEquals(arrA, arrB, true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason to check the other iterations / combinations of the 3rd and 4th params here? I assume not, just checking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is fine as-is.

expect(arrA[0]).toBe('b');
expect(arrB[0]).toBe('a');
});

test('handles delimiter collision', () => {
expect(arrayEquals(['a;b', 'c'], ['a', 'b;c'])).toBeFalsy();
});

test('handles numeric-string collisions', () => {
expect(arrayEquals(['1', '23'], ['12', '3'])).toBeFalsy();
});

test('handles duplicate elements correctly with ignoreOrder', () => {
expect(arrayEquals(['a', 'a', 'b'], ['a', 'b', 'b'], true)).toBeFalsy();
expect(arrayEquals(['a', 'a', 'b'], ['a', 'b', 'b'], false)).toBeFalsy();
expect(arrayEquals(['a', 'a', 'b'], ['a', 'b', 'a'], true)).toBeTruthy();
expect(arrayEquals(['a', 'a', 'b'], ['a', 'b', 'a'], false)).toBeFalsy();
});
});

describe('getValueFromRow', () => {
test('no row', () => {
expect(getValueFromRow(undefined, 'Name')).toEqual(undefined);
expect(getValueFromRow({}, 'Name')).toEqual(undefined);
expect(getValueFromRow(undefined, 'Name')).toBeUndefined();
expect(getValueFromRow({}, 'Name')).toBeUndefined();
});

test('returns value', () => {
const row = { Name: 'test' };
expect(getValueFromRow(row, 'Name')).toEqual('test');
expect(getValueFromRow(row, 'name')).toEqual('test');
expect(getValueFromRow(row, 'bogus')).toEqual(undefined);
expect(getValueFromRow(row, 'bogus')).toBeUndefined();
});

test('returns value from object', () => {
const row = { Name: { value: 'test' } };
expect(getValueFromRow(row, 'Name')).toEqual('test');
expect(getValueFromRow(row, 'name')).toEqual('test');
expect(getValueFromRow(row, 'bogus')).toEqual(undefined);
expect(getValueFromRow(row, 'bogus')).toBeUndefined();
});

test('returns value from array', () => {
const flatRow = { Name: ['test1', 'test2'] };
expect(getValueFromRow(flatRow, 'Name')).toEqual(undefined);
expect(getValueFromRow(flatRow, 'name')).toEqual(undefined);
expect(getValueFromRow(flatRow, 'bogus')).toEqual(undefined);
expect(getValueFromRow(flatRow, 'Name')).toBeUndefined();
expect(getValueFromRow(flatRow, 'name')).toBeUndefined();
expect(getValueFromRow(flatRow, 'bogus')).toBeUndefined();

const nestedRow = { Name: [{ value: 'test1' }, { value: 'test2' }] };
expect(getValueFromRow(nestedRow, 'Name')).toEqual('test1');
expect(getValueFromRow(nestedRow, 'name')).toEqual('test1');
expect(getValueFromRow(nestedRow, 'bogus')).toEqual(undefined);
expect(getValueFromRow(nestedRow, 'bogus')).toBeUndefined();
});
});

Expand Down
19 changes: 15 additions & 4 deletions packages/components/src/internal/util/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,16 +748,27 @@ export function isQuotedWithDelimiters(value: any, delimiter: string): boolean {
return strVal.startsWith('"') && strVal.endsWith('"');
}

export function arrayEquals(a: string[], b: string[], ignoreOrder = true, caseInsensitive?: boolean): boolean {
export function arrayEquals(
a: null | string[] | undefined,
b: null | string[] | undefined,
ignoreOrder = true,
caseInsensitive = false
): boolean {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;

const aStr = ignoreOrder ? a.sort().join(';') : a.join(';');
const bStr = ignoreOrder ? b.sort().join(';') : b.join(';');
const normalize = (s: string) => (caseInsensitive ? s.toLowerCase() : s);

return caseInsensitive ? aStr.toLowerCase() === bStr.toLowerCase() : aStr === bStr;
if (ignoreOrder) {
// Use a copy to avoid mutating the original arrays
const aSorted = [...a].map(normalize).sort();
const bSorted = [...b].map(normalize).sort();
return aSorted.every((val, index) => val === bSorted[index]);
}

return a.every((val, index) => normalize(val) === normalize(b[index]));
}

export function getValueFromRow(row: Record<string, any>, col: string): number | string {
Expand Down
Loading
Loading