diff --git a/CHANGELOG.md b/CHANGELOG.md index 52414cc2..8fc43abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### 1.43.0 - 2025-08-29 +- Support `File` values directly in `Assay.importRun()` for `batchProperties` and (run) `properties` +- Can only be run with corresponding server-side changes in LabKey v25.09 + ### 1.42.1 - 2025-07-24 - Issue 53243: add "includeEmptyPermGroups" to getGroupPermissions diff --git a/package-lock.json b/package-lock.json index 77a0c982..8b472032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/api", - "version": "1.42.1", + "version": "1.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/api", - "version": "1.42.1", + "version": "1.43.0", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.27.4", diff --git a/package.json b/package.json index ea600c16..36bf33fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/api", - "version": "1.42.1", + "version": "1.43.0", "description": "JavaScript client API for LabKey Server", "scripts": { "build": "npm run build:dist && npm run build:docs", diff --git a/src/labkey/dom/Assay.spec.ts b/src/labkey/dom/Assay.spec.ts new file mode 100644 index 00000000..a97b117f --- /dev/null +++ b/src/labkey/dom/Assay.spec.ts @@ -0,0 +1,88 @@ +import * as Ajax from '../Ajax'; + +import { importRun } from './Assay'; + +describe('dom/Assay', () => { + const requestSpy = jest.spyOn(Ajax, 'request').mockImplementation(); + + describe('importRun', () => { + const TEST_FILE = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + const TEST_PROPS = { + array_primitive: [1, 2, 3], + array_mixed: ['a', 2, true, null, undefined, { z: 9 }], + bigNumber: 9007199254740991, + bigNumberTooBig: 9007199254740991456, + date_object: new Date('2025-08-23T12:34:56.789Z'), + 'emoji_🙂': 'smile', + empty_array: [] as any[], + empty_object: {}, + nullValue: null as any, + 'qu"ot\'ed"key': 'quotes" are \'ok"', + someFile: TEST_FILE, + someInt: 42, + undefinedValue: undefined as any, + 'unicode_µg/μL': 'micro units', + 'with[brackets]': 'square brackets', + 'with[one_bracket': 'one [bracket', + }; + + function verifyProperties(formData: FormData, propertyFn: (name: string) => string): void { + expect(formData.get(propertyFn('array_primitive'))).toBe('[1,2,3]'); + expect(formData.get(propertyFn('array_mixed'))).toBe('[\"a\",2,true,null,null,{\"z\":9}]'); + expect(formData.get(propertyFn('bigNumber'))).toBe('9007199254740991'); + expect(formData.get(propertyFn('bigNumberTooBig'))).toBe('9007199254740991000'); + expect(formData.get(propertyFn('emoji_🙂'))).toBe('smile'); + expect(formData.get(propertyFn('empty_array'))).toBe('[]'); + expect(formData.get(propertyFn('empty_object'))).toBe('{}'); + expect(formData.get(propertyFn('date_object'))).toBe('Sat Aug 23 2025 12:34:56 GMT+0000 (Coordinated Universal Time)'); + expect(formData.get(propertyFn('nullValue'))).toBe('null'); + expect(formData.get(propertyFn('qu"ot\'ed"key'))).toBe('quotes" are \'ok"'); + expect(formData.get(propertyFn('someFile'))).toBe(TEST_FILE); + expect(formData.get(propertyFn('someInt'))).toBe('42'); + expect(formData.has(propertyFn('undefinedValue'))).toBe(false); + expect(formData.get(propertyFn('unicode_µg/μL'))).toBe('micro units'); + expect(formData.get(propertyFn('with[brackets]'))).toBe('square brackets'); + expect(formData.get(propertyFn('with[one_bracket'))).toBe('one [bracket'); + } + + function getFormData(lastArgs: Ajax.RequestOptions[]): FormData { + expect(lastArgs).toHaveLength(1); + expect(lastArgs).toHaveLength(1); + const request = lastArgs[0]; + expect(request.form instanceof FormData).toBe(true); + return request.form as FormData; + } + + it('does not serialize empty properties', () => { + // Act + importRun({ + assayId: 123, + batchProperties: {}, + dataRows: [], + properties: {}, + }); + + // Assert + const formData = getFormData(requestSpy.mock.lastCall); + const formKeys = formData.keys(); + expect(formKeys).not.toContain('batchProperties'); + expect(formKeys).not.toContain('properties'); + }); + it('serializes batch properties', () => { + // Act + importRun({ assayId: 123, batchProperties: TEST_PROPS, dataRows: [] }); + + // Assert + const formData = getFormData(requestSpy.mock.lastCall); + verifyProperties(formData, (name: string) => `batchProperties[\'${name}\']`); + }); + it('serializes run properties', () => { + // Act + importRun({ assayId: 123, dataRows: [], properties: TEST_PROPS }); + + // Assert + const formData = getFormData(requestSpy.mock.lastCall); + verifyProperties(formData, (name: string) => `properties[\'${name}\']`); + }); + }); +}); \ No newline at end of file diff --git a/src/labkey/dom/Assay.ts b/src/labkey/dom/Assay.ts index 584bc6bd..d7d26122 100644 --- a/src/labkey/dom/Assay.ts +++ b/src/labkey/dom/Assay.ts @@ -17,13 +17,34 @@ import { buildURL } from '../ActionURL'; import { request } from '../Ajax'; import { getCallbackWrapper, getOnFailure, getOnSuccess, isObject, RequestCallbackOptions } from '../Utils'; +// CONSIDER: Simplifying serialization by calling JSON.stringify() on the entire properties object and placing that +// on the form. We would pluck out the file values (like we do for Query.saveRows()). This would require API changes. +function appendProperties(propName: string, formData: FormData, properties: Record): void { + if (!properties) return; + + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + + let formValue; + if (value instanceof File) { + formValue = value; + } else if (isObject(value) || Array.isArray(value)) { + formValue = JSON.stringify(value); + } else { + formValue = value; + } + + formData.append(`${propName}['${key}']`, formValue); + } +} + export interface ImportRunOptions extends RequestCallbackOptions { allowCrossRunFileInputs?: boolean; allowLookupByAlternateKey?: boolean; assayId?: number | string; auditUserComment?: string; batchId?: number | string; - batchProperties?: any; + batchProperties?: Record; comment?: string; comments?: string; containerPath?: string; @@ -34,7 +55,7 @@ export interface ImportRunOptions extends RequestCallbackOptions { jobNotificationProvider?: string; name?: string; plateMetadata?: any; - properties?: any; + properties?: Record; reRunId?: number | string; resultsFiles?: File[]; runFilePath?: string; @@ -116,25 +137,8 @@ export function importRun(options: ImportRunOptions): XMLHttpRequest { formData.append('auditUserComment', options.auditUserComment); } - if (options.properties) { - for (const [key, value] of Object.entries(options.properties)) { - if (isObject(value)) { - formData.append(`properties['${key}']`, JSON.stringify(value)); - } else { - formData.append(`properties['${key}']`, options.properties[key]); - } - } - } - - if (options.batchProperties) { - for (const [key, value] of Object.entries(options.batchProperties)) { - if (isObject(value)) { - formData.append(`batchProperties['${key}']`, JSON.stringify(value)); - } else { - formData.append(`batchProperties['${key}']`, options.batchProperties[key]); - } - } - } + appendProperties('batchProperties', formData, options.batchProperties); + appendProperties('properties', formData, options.properties); if (options.dataRows) { formData.append('dataRows', JSON.stringify(options.dataRows));