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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions 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 package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
88 changes: 88 additions & 0 deletions src/labkey/dom/Assay.spec.ts
Original file line number Diff line number Diff line change
@@ -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}\']`);
});
});
});
46 changes: 25 additions & 21 deletions src/labkey/dom/Assay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>): 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<string, any>;
comment?: string;
comments?: string;
containerPath?: string;
Expand All @@ -34,7 +55,7 @@ export interface ImportRunOptions extends RequestCallbackOptions {
jobNotificationProvider?: string;
name?: string;
plateMetadata?: any;
properties?: any;
properties?: Record<string, any>;
reRunId?: number | string;
resultsFiles?: File[];
runFilePath?: string;
Expand Down Expand Up @@ -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));
Expand Down