From f92754d23aaa2c590165e4134dc47ee89b64796b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 20 Mar 2026 14:03:34 -0700 Subject: [PATCH 1/9] arrayEquals: fix edge cases --- .../src/internal/util/utils.test.ts | 27 +++++++++++++++++++ .../components/src/internal/util/utils.ts | 19 ++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index ec4d3963e8..39ebcdc487 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -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(); @@ -1621,6 +1622,32 @@ 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); + expect(arrA[0]).toBe('b'); + expect(arrB[0]).toBe('a'); + }); + + test('handles delimiter collision (Accuracy Check)', () => { + const arrA = ['a;b', 'c']; + const arrB = ['a', 'b;c']; + expect(arrayEquals(arrA, arrB)).toBeFalsy(); + }); + + test('handles numeric-string collisions', () => { + const arrA = ['1', '23']; + const arrB = ['12', '3']; + expect(arrayEquals(arrA, arrB)).toBeFalsy(); + }); + + test('handles duplicate elements correctly with ignoreOrder', () => { + const arrA = ['a', 'a', 'b']; + const arrB = ['a', 'b', 'b']; + expect(arrayEquals(arrA, arrB, true)).toBeFalsy(); + }); }); describe('getValueFromRow', () => { diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index f4612e111a..790bc6bb91 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -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, col: string): number | string { From a8c783f5237e0dda9b6bb4ee95536d72cbbfbc8b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 20 Mar 2026 14:04:08 -0700 Subject: [PATCH 2/9] extractChanges: support column.jsonType "array" --- .../components/forms/detail/utils.test.ts | 53 ++++++++++++++----- .../internal/components/forms/detail/utils.ts | 9 ++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/components/src/internal/components/forms/detail/utils.test.ts b/packages/components/src/internal/components/forms/detail/utils.test.ts index 582c16ca92..3f5f5b82d5 100644 --- a/packages/components/src/internal/components/forms/detail/utils.test.ts +++ b/packages/components/src/internal/components/forms/detail/utils.test.ts @@ -1,4 +1,4 @@ -import { fromJS } from 'immutable'; +import { fromJS, List } from 'immutable'; import { QueryColumn } from '../../../../public/QueryColumn'; import { QueryInfo } from '../../../../public/QueryInfo'; @@ -26,13 +26,21 @@ 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, }, }); @@ -40,10 +48,10 @@ 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(); @@ -51,22 +59,22 @@ describe('extractChanges', () => { 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 @@ -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(); + }); }); diff --git a/packages/components/src/internal/components/forms/detail/utils.ts b/packages/components/src/internal/components/forms/detail/utils.ts index 05d81e5275..4885f3f6a5 100644 --- a/packages/components/src/internal/components/forms/detail/utils.ts +++ b/packages/components/src/internal/components/forms/detail/utils.ts @@ -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, nestedModelList: List>): boolean { let matched = 0; @@ -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; From 77d850c3ef2770895efbb38f72e9fb1df9f762a5 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 20 Mar 2026 14:04:41 -0700 Subject: [PATCH 3/9] EditableDetailPanel: refactor to compare against model with update columns --- .../public/QueryModel/EditableDetailPanel.tsx | 248 ++++++++++-------- 1 file changed, 144 insertions(+), 104 deletions(-) diff --git a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx index 6bbe16abfb..81ad74d57a 100644 --- a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx +++ b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactNode, useCallback, useState } from 'react'; +import React, { FC, ReactNode, useCallback, useMemo, useState } from 'react'; import { fromJS } from 'immutable'; import { Query } from '@labkey/api'; @@ -20,7 +20,8 @@ import { useAppContext } from '../../internal/AppContext'; import { QueryModel } from './QueryModel'; -import { DetailPanel, DetailPanelWithModel } from './DetailPanel'; +import { DetailPanel } from './DetailPanel'; +import { InjectedQueryModels, withQueryModels } from './withQueryModels'; import { EDIT_METHOD } from '../../internal/constants'; import { useRouteLeave } from '../../internal/util/RouteLeave'; @@ -47,38 +48,42 @@ export interface EditableDetailPanelProps { title?: string; } -export const EditableDetailPanel: FC = props => { +interface EditingFormProps extends Omit { + onCancel: () => void; +} + +const EditingFormImpl: FC = props => { const { + asSubPanel, + canUpdate, + containerFilter, containerPath, - model, + detailEditRenderer, + detailRenderer, + disabled, + editColumns, + internalSpacesWarningFieldKeys, + onAdditionalFormDataChange, onBeforeUpdate, + onCancel, onCommentChange, onEditToggle, onUpdate, - appEditable, - containerFilter, - disabled, - detailEditRenderer, - detailHeader, - detailRenderer, - internalSpacesWarningFieldKeys, - asSubPanel, - canUpdate, - editColumns, - queryColumns, + queryModels, submitText = 'Save', title, - onAdditionalFormDataChange, } = props; + const editModel = queryModels.model; const { api } = useAppContext(); const [_, setIsDirty] = useRouteLeave(); const [canSubmit, setCanSubmit] = useState(false); - const [editing, setEditing] = useState(false); const [error, setError] = useState(undefined); const [warning, setWarning] = useState(undefined); const [comment, setComment] = useState(); const { requiresUserComment } = useDataChangeCommentsRequired(); + const hasValidUserComment = comment?.trim()?.length > 0; + const _onCommentChange = useCallback( _comment => { setComment(_comment); @@ -87,17 +92,6 @@ export const EditableDetailPanel: FC = props => { [onCommentChange] ); - const hasValidUserComment = comment?.trim()?.length > 0; - - const toggleEditing = useCallback((): void => { - const updated = !editing; - setEditing(updated); - setIsDirty(false); - setWarning(undefined); - setError(undefined); - onEditToggle?.(updated); - }, [editing, onEditToggle, setIsDirty]); - const disableSubmitButton = useCallback((): void => { setCanSubmit(false); }, []); @@ -120,9 +114,9 @@ export const EditableDetailPanel: FC = props => { const handleSubmit = useCallback( async (values: Record): Promise => { - const { queryInfo } = model; - const row = model.getRow(); - const updatedValues = extractChanges(queryInfo, fromJS(model.getRow()), values); + const { queryInfo } = editModel; + const row = editModel.getRow(); + const updatedValues = extractChanges(queryInfo, fromJS(editModel.getRow()), values); if (Object.keys(updatedValues).length === 0) { setCanSubmit(false); @@ -155,7 +149,7 @@ export const EditableDetailPanel: FC = props => { }); setIsDirty(false); - setEditing(false); + onCancel(); onUpdate?.(); onEditToggle?.(false); } catch (e) { @@ -163,41 +157,24 @@ export const EditableDetailPanel: FC = props => { setWarning(undefined); } }, - [model, onBeforeUpdate, api.query, containerPath, comment, onUpdate, onEditToggle, setIsDirty] + [api.query, comment, containerPath, editModel, onBeforeUpdate, onCancel, onEditToggle, onUpdate, setIsDirty] ); - const isEditable = !model.isLoading && model.hasRows && (model.queryInfo?.isAppEditable() || appEditable); + return ( + +
+ - const panel = ( -
- - -
-
- {error && {error}} +
+
+ {error && {error}} - {!editing && (detailHeader ?? null)} - - {!editing && ( - )} - - {/* When editing load a model that includes the update columns and editing mode rendering */} - {editing && ( - = props => { editingMode fileInputRenderer={fileInputRenderer} internalSpacesWarningFieldKeys={internalSpacesWarningFieldKeys} + model={editModel} onAdditionalFormDataChange={onAdditionalFormDataChange} - queryConfig={{ - ...model.queryConfig, - // Issue 46478: Include update columns in request columns to ensure values are available - requiredColumns: model.requiredColumns.concat( - model.updateColumns.map(col => col.fieldKey) - ), - }} /> - )} +
-
+ + + + + + + + {asSubPanel &&
} + + ); +}; + +const EditingFormWithModels = withQueryModels(EditingFormImpl); + +// Lazy wrapper: only mounted when editing, builds the edit-mode queryConfig and key +const EditingForm: FC = props => { + const { model } = props; + const queryConfig = useMemo( + () => ({ + ...model.queryConfig, + // Issue 46478: Include update columns in request columns to ensure values are available + requiredColumns: model.requiredColumns.concat(model.updateColumns.map(col => col.fieldKey)), + }), + [model] ); + const queryConfigs = useMemo(() => ({ model: queryConfig }), [queryConfig]); + const { keyValue, schemaQuery } = queryConfig; + const { schemaName, queryName } = schemaQuery; + // Key ensures we re-mount when the queryConfig identity changes + const key = `${schemaName}.${queryName}.${keyValue}`; + + return ; +}; + +export const EditableDetailPanel: FC = props => { + const { + appEditable, + canUpdate, + containerFilter, + containerPath, + detailHeader, + detailRenderer, + model, + onEditToggle, + queryColumns, + title, + } = props; + + const [_, setIsDirty] = useRouteLeave(); + const [editing, setEditing] = useState(false); + + const toggleEditing = useCallback((): void => { + setEditing(true); + setIsDirty(false); + onEditToggle?.(true); + }, [onEditToggle, setIsDirty]); + + const handleCancel = useCallback((): void => { + setEditing(false); + setIsDirty(false); + onEditToggle?.(false); + }, [onEditToggle, setIsDirty]); + + const isEditable = !model.isLoading && model.hasRows && (model.queryInfo?.isAppEditable() || appEditable); if (editing) { - return ( - - {panel} - - - - - - - - {asSubPanel &&
} - - ); + return ; } - return panel; + return ( +
+ + +
+
+ {detailHeader ?? null} + + +
+
+
+ ); }; EditableDetailPanel.displayName = 'EditableDetailPanel'; From 1d9eddf201865806ceb8b99caf8248f913056642 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 20 Mar 2026 14:27:10 -0700 Subject: [PATCH 4/9] Remove comments --- .../components/src/public/QueryModel/EditableDetailPanel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx index 81ad74d57a..80e3dbc32c 100644 --- a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx +++ b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx @@ -217,7 +217,6 @@ const EditingFormImpl: FC = props => { const EditingFormWithModels = withQueryModels(EditingFormImpl); -// Lazy wrapper: only mounted when editing, builds the edit-mode queryConfig and key const EditingForm: FC = props => { const { model } = props; const queryConfig = useMemo( @@ -231,7 +230,6 @@ const EditingForm: FC = props => { const queryConfigs = useMemo(() => ({ model: queryConfig }), [queryConfig]); const { keyValue, schemaQuery } = queryConfig; const { schemaName, queryName } = schemaQuery; - // Key ensures we re-mount when the queryConfig identity changes const key = `${schemaName}.${queryName}.${keyValue}`; return ; From cc221e7c9ffd8065de7d776cacb31b60557d15be Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 20 Mar 2026 14:34:01 -0700 Subject: [PATCH 5/9] test updates --- .../src/internal/util/utils.test.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 39ebcdc487..4f6336b8b3 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -1631,55 +1631,49 @@ describe('arrayEquals', () => { expect(arrB[0]).toBe('a'); }); - test('handles delimiter collision (Accuracy Check)', () => { - const arrA = ['a;b', 'c']; - const arrB = ['a', 'b;c']; - expect(arrayEquals(arrA, arrB)).toBeFalsy(); + test('handles delimiter collision', () => { + expect(arrayEquals(['a;b', 'c'], ['a', 'b;c'])).toBeFalsy(); }); test('handles numeric-string collisions', () => { - const arrA = ['1', '23']; - const arrB = ['12', '3']; - expect(arrayEquals(arrA, arrB)).toBeFalsy(); + expect(arrayEquals(['1', '23'], ['12', '3'])).toBeFalsy(); }); test('handles duplicate elements correctly with ignoreOrder', () => { - const arrA = ['a', 'a', 'b']; - const arrB = ['a', 'b', 'b']; - expect(arrayEquals(arrA, arrB, true)).toBeFalsy(); + expect(arrayEquals(['a', 'a', 'b'], ['a', 'b', 'b'], true)).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(); }); }); From 09445b8e62bad383378403421c55702576236a7e Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 23 Mar 2026 14:29:35 -0700 Subject: [PATCH 6/9] More checks --- packages/components/src/internal/util/utils.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 4f6336b8b3..e445c3ac9a 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -1641,6 +1641,9 @@ describe('arrayEquals', () => { 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(); }); }); From d0bbff6ea04defb15851610b8cc09ad4f8c32f54 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 24 Mar 2026 12:06:34 -0700 Subject: [PATCH 7/9] 7.24.1-fb-mv-edit-960.0 --- 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 91f6663872..70a30b53bb 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.24.0", + "version": "7.24.1-fb-mv-edit-960.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.24.0", + "version": "7.24.1-fb-mv-edit-960.0", "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 9351be862f..fdacc9b3e5 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.24.0", + "version": "7.24.1-fb-mv-edit-960.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 855bc6b37ece9e8a4c9ec4deb361cdaf945d0528 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 25 Mar 2026 08:10:58 -0700 Subject: [PATCH 8/9] Prepare release notes --- packages/components/releaseNotes/components.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 6f82db54b4..a352005755 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -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 From d679c5ae0384eaba1cb660647d3d9134327b1758 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 25 Mar 2026 08:11:12 -0700 Subject: [PATCH 9/9] 7.24.1 --- 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 70a30b53bb..55d169e8b9 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.24.1-fb-mv-edit-960.0", + "version": "7.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.24.1-fb-mv-edit-960.0", + "version": "7.24.1", "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 fdacc9b3e5..19e985a1a3 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.24.1-fb-mv-edit-960.0", + "version": "7.24.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [