Skip to content
Open
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 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.49.1",
"version": "1.49.2-fb-encodeFormName.0",
"description": "JavaScript client API for LabKey Server",
"scripts": {
"build": "npm run build:dist && npm run build:docs",
Expand Down
28 changes: 28 additions & 0 deletions src/labkey/Utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ describe('caseInsensitiveEquals', () => {
});
});

describe('encodeFormName', () => {
test('empty', () => {
expect(Utils.encodeFormName(null)).toBeNull();
expect(Utils.encodeFormName(undefined)).toBeUndefined();
expect(Utils.encodeFormName('')).toBe('');
expect(Utils.encodeFormName(' ')).toBe(' ');
});

test('no relevant special character', () => {
expect(Utils.encodeFormName('a')).toBe('a');
expect(Utils.encodeFormName('$')).toBe('$');
expect(Utils.encodeFormName('9')).toBe('9');
expect(Utils.encodeFormName('[a]')).toBe('[a]');
});

test('encoded', () => {
expect(Utils.encodeFormName('"')).toBe('%_%22');
expect(Utils.encodeFormName('%')).toBe('%_%25');
expect(Utils.encodeFormName('%_beep')).toBe('%_%25_beep');
expect(Utils.encodeFormName('""')).toBe('%_%22%22');
expect(Utils.encodeFormName('"22')).toBe('%_%2222');
expect(Utils.encodeFormName('"a"')).toBe('%_%22a%22');
expect(Utils.encodeFormName('a%22')).toBe('%_a%2522');
expect(Utils.encodeFormName('"a%22')).toBe('%_%22a%2522');
expect(Utils.encodeFormName('"a%222')).toBe('%_%22a%25222');
});
});

describe('ensureRegionName', () => {
it('should return default', () => {
expect(Utils.ensureRegionName()).toEqual('query');
Expand Down
13 changes: 13 additions & 0 deletions src/labkey/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,19 @@ export function encode(data: any): string {
return JSON.stringify(data);
}

/**
* Encodes a form value name for submission to a LabKey Server.
*
* @param name The form value name to encode.
* @return The encoded form value name.
*/
export function encodeFormName(name: string): string {
// Issue 52925, Issue 52119, Issue 54218
// Should be consistent with PageFlowUtil.encodeFormName() on the server
if (!name || !/[\\"%]/.test(name)) return name;
return '%_' + encodeURIComponent(name);
}

/**
* Encodes the html passed in and converts it to a String so that it will not be interpreted as HTML
* by the browser. For example, if your input string was "<p>Hello</p>" the output would be
Expand Down
22 changes: 18 additions & 4 deletions src/labkey/query/Rows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,35 +351,49 @@ describe('bindSaveRowsData', () => {
const fileA = new File([], '');
const fileB = new File([], '');
const fileC = new File([], '');
const fileD = new File([], '');
const fileE = new File([], '');
const form = bindSaveRowsData({
commands: [
{
...baseCommand,
rows: [
{ myFile: fileA, rowId: 1 },
{ myFile: fileB, rowId: 2 },
{ 'file"Name': fileC, rowId: 3 },
{ 'file\\Name': fileD, rowId: 4 },
],
},
{ ...baseCommand, rows: [{ myFile: fileC, rowId: 3 }] },
{ ...baseCommand, rows: [{ myFile: fileE, rowId: 5 }] },
],
});
expect(Array.from(form.keys()).sort()).toEqual([
'%_file%22Name::0::2',
'%_file%5CName::0::3',
'json',
'myFile::0::0',
'myFile::0::1',
'myFile::1::0',
]);
expect(form.get('myFile::0::0')).toEqual(fileA);
expect(form.get('myFile::0::1')).toEqual(fileB);
expect(form.get('myFile::1::0')).toEqual(fileC);
expect(form.get('%_file%22Name::0::2')).toEqual(fileC);
expect(form.get('%_file%5CName::0::3')).toEqual(fileD);
expect(form.get('myFile::1::0')).toEqual(fileE);
expect(form.get('json')).toEqual(
JSON.stringify({
commands: [
{
schemaName: 'schema',
queryName: 'query',
command: 'update',
rows: [{ rowId: 1 }, { rowId: 2 }],
rows: [{ rowId: 1 }, { rowId: 2 }, { rowId: 3 }, { rowId: 4 }],
},
{
schemaName: 'schema',
queryName: 'query',
command: 'update',
rows: [{ rowId: 3 }],
rows: [{ rowId: 5 }],
},
],
})
Expand Down
30 changes: 14 additions & 16 deletions src/labkey/query/Rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
import { request, RequestOptions } from '../Ajax';
import { buildURL } from '../ActionURL';
import { getCallbackWrapper, getOnFailure, getOnSuccess, RequestCallbackOptions } from '../Utils';
import { encodeFormName, getCallbackWrapper, getOnFailure, getOnSuccess, RequestCallbackOptions } from '../Utils';
import { AuditBehaviorTypes } from '../constants';

export interface QueryRequestOptions extends RequestCallbackOptions {
Expand Down Expand Up @@ -273,18 +273,17 @@ export interface SaveRowsResponse {
}

export interface SaveRowsOptions extends RequestCallbackOptions<SaveRowsResponse> {

/**
* Optional audit details to record in the transaction audit log for this command.
*/
auditDetails?: Record<string, any>;
/**
* Version of the API. If this is 13.2 or higher, a request that fails
* validation will be returned as a successful response. Use the 'errorCount' and 'committed' properties in the
* response to tell if it committed or not. If this is 13.1 or lower (or unspecified), the failure callback
* will be invoked instead in the event of a validation failure.
*/
apiVersion?: string | number;
apiVersion?: number | string;
/**
* Optional audit details to record in the transaction audit log for this command.
*/
auditDetails?: Record<string, any>;
/** An array of the update/insert/delete operations to be performed. */
commands: Command[];
/**
Expand All @@ -303,15 +302,14 @@ export interface SaveRowsOptions extends RequestCallbackOptions<SaveRowsResponse
*/
timeout?: number;
/**
* Whether all of the row changes for all of the tables
* should be done in a single transaction, so they all succeed or all fail. Defaults to true.
* Whether all the row changes for all the tables should be done in a single transaction,
* so they all succeed or all fail. Defaults to true.
*/
transacted?: boolean;
/**
* Whether or not the server should attempt proceed through all of the
* commands, but not actually commit them to the database. Useful for scenarios like giving incremental
* validation feedback as a user fills out a UI form, but not actually save anything until they explicitly request
* a save.
* Whether the server should attempt to proceed through all the commands but not commit them to the database.
* Useful for scenarios like giving incremental validation feedback as a user fills out a UI form but does not save
* anything until they explicitly request a save.
*/
validateOnly?: boolean;
}
Expand All @@ -336,7 +334,7 @@ function bindSaveRowsCommand(form: FormData, command: Command, commandIndex: num

Object.keys(updatedRow).forEach(key => {
if (updatedRow[key] instanceof File) {
form.append(`${key}::${commandIndex}::${rowIndex}`, updatedRow[key]);
form.append(`${encodeFormName(key)}::${commandIndex}::${rowIndex}`, updatedRow[key]);
delete updatedRow[key];
}
});
Expand Down Expand Up @@ -420,7 +418,7 @@ export function bindFormData(jsonData: { rows?: any[] }, options: SendRequestOpt
form = new FormData();

// Process and extract File data with row offsets
const rows: Array<Record<string, any>> = [];
const rows: Record<string, any>[] = [];

jsonData.rows.forEach((row, i) => {
if (row) {
Expand All @@ -429,7 +427,7 @@ export function bindFormData(jsonData: { rows?: any[] }, options: SendRequestOpt
Object.keys(row).forEach(k => {
// Extract File values from the row
if (row[k] instanceof File) {
form.append(`${k}::${i}`, row[k]);
form.append(`${encodeFormName(k)}::${i}`, row[k]);
} else {
_row[k] = row[k];
}
Expand Down
Loading