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 packages/agent-client/src/action-fields/action-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default abstract class ActionField {
return this.field?.getTypeName();
}

getEffectiveTypeName(): string {
Comment thread
Scra3 marked this conversation as resolved.
return this.field?.getEffectiveTypeName();
}

getValue() {
return this.field?.getValue();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default class FieldFormStates {
const field = this.getField(name);
if (!field) throw new Error(`Field "${name}" not found in action "${this.actionName}"`);

field.getPlainField().value = encodeFileFieldValue(field.getTypeName(), value, name);
field.getPlainField().value = encodeFileFieldValue(field.getEffectiveTypeName(), value, name);

const fieldHasHook = field.getPlainField().hook;

Expand Down
22 changes: 19 additions & 3 deletions packages/agent-client/src/action-fields/field-getter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,29 @@ export default class FieldGetter {
}

/**
* The same type as a single name, `['File']` becoming `'FileList'`. For dispatching on the type
* and for reporting it to a reader; never for anything that goes back to an agent, which echoes
* `plainField` verbatim through loadChanges and matches only the array form.
* The declared type as a single name, `['File']` becoming `'FileList'`. For dispatching on the
* declared type — the Enum branch of getActionForm — never for anything that goes back to an
* agent, which echoes `plainField` verbatim through loadChanges and matches only the array form.
*/
getTypeName(): string {
const { type } = this.plainField;

return Array.isArray(type) ? `${type[0]}List` : type;
}

/**
* What the field really holds, and what a reader is told. A v1 liana has no File type in its
* action forms: it declares a file input as a String and says so only through the widget, so a
* declared type read on its own would miss it. Use this to report a type or to encode a value;
* use getTypeName when the declared type is what matters.
*/
getEffectiveTypeName(): string {
Comment thread
Scra3 marked this conversation as resolved.
const typeName = this.getTypeName();
const hasFilePickerWidget = this.plainField.widgetEdit?.name === 'file picker';

if (hasFilePickerWidget && typeName === 'String') return 'File';
if (hasFilePickerWidget && typeName === 'StringList') return 'FileList';

return typeName;
}
}
7 changes: 6 additions & 1 deletion packages/agent-client/src/action-fields/file-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export default function encodeFileFieldValue(
// A file reaching a field that is not declared as one is never intentional, and it would be
// JSON-serialized into the column as {"buffer":{"type":"Buffer",...}} without any error.
if (isFile(value)) {
throw fileError(fieldName, `is a ${type} field and cannot hold a file.`);
throw fileError(
fieldName,
`takes ${type}, not a file: send the file to a File field instead. If this field is ` +
'meant to take one, the action must declare it as type File or carry the ' +
'"file picker" widget.',
);
}

return value;
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-client/src/action-fields/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export type PlainField = {
isReadOnly: boolean;
hook?: string;
widgetEdit?: {
name?: string;
Comment thread
Scra3 marked this conversation as resolved.
// `static` is the choice widgets' slice. A file picker carries none of it: v1 emits an empty
// object, v2 the upload constraints — so requiring it would only make fixtures invent one.
parameters: {
static: {
static?: {
options?: PlainFieldOption[];
enableOpacity?: boolean;
quickPalette?: string[];
Expand Down
80 changes: 80 additions & 0 deletions packages/agent-client/test/action-fields/action-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ActionFieldRadioGroup from '../../src/action-fields/action-field-radio-gr
import ActionFieldString from '../../src/action-fields/action-field-string';
import ActionFieldStringList from '../../src/action-fields/action-field-string-list';
import FieldFormStates from '../../src/action-fields/field-form-states';
import Action from '../../src/domains/action';

jest.mock('../../src/http-requester');

Expand Down Expand Up @@ -864,5 +865,84 @@ describe('ActionField implementations', () => {
const field = new ActionFieldString('testField', fieldFormStates);
expect(field.isRequired()).toBe(true);
});

it('should report a String carrying the file picker widget as File while keeping its declared type', async () => {
await setupFields([
{
field: 'file_0',
type: 'String',
isRequired: true,
isReadOnly: false,
widgetEdit: { name: 'file picker', parameters: {} },
},
]);

const field = new ActionFieldString('file_0', fieldFormStates);

expect(field.getEffectiveTypeName()).toBe('File');
expect(field.getType()).toBe('String');
});

it('should report a String list carrying the file picker widget as FileList', async () => {
await setupFields([
{
field: 'files',
type: ['String'],
isRequired: false,
isReadOnly: false,
widgetEdit: { name: 'file picker', parameters: {} },
},
]);

const field = new ActionFieldString('files', fieldFormStates);

expect(field.getEffectiveTypeName()).toBe('FileList');
});

it('should report a String without the file picker widget as String', () => {
const field = new ActionFieldString('testField', fieldFormStates);
expect(field.getEffectiveTypeName()).toBe('String');
});
});

describe('Action.getFields on a v1 file picker form', () => {
it('should return a String-typed field that reports itself as File', async () => {
httpRequester.query.mockResolvedValue({
fields: [
{
field: 'doc_type_0',
type: 'Enum',
isRequired: true,
isReadOnly: false,
enums: ['passport', 'id_card'],
},
{
field: 'file_0',
type: 'String',
isRequired: true,
isReadOnly: false,
widgetEdit: { name: 'file picker', parameters: {} },
},
],
layout: [],
});

await fieldFormStates.loadInitialState();

const action = new Action(
'users',
'Upload doc',
httpRequester,
'/forest/actions/test',
fieldFormStates,
['1'],
);
const [docType, file] = action.getFields();

expect(file).toBeInstanceOf(ActionFieldString);
expect(file.getType()).toBe('String');
expect(file.getEffectiveTypeName()).toBe('File');
expect(docType.getEffectiveTypeName()).toBe('Enum');
});
});
});
140 changes: 140 additions & 0 deletions packages/agent-client/test/action-fields/field-form-states.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,4 +699,144 @@ describe('FieldFormStates', () => {
expect(httpRequester.query).not.toHaveBeenCalled();
});
});

describe('on a v1 form declaring its file field as a String with the file picker widget', () => {
const pdf = { mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4'), name: 'kyc.pdf' };
const pdfDataUri = `data:application/pdf;name=kyc.pdf;base64,${Buffer.from('%PDF-1.4').toString(
'base64',
)}`;
const filePicker = { name: 'file picker', parameters: {} };

const loadedFields = [
{
field: 'doc_type_0',
type: 'Enum',
isRequired: true,
isReadOnly: false,
value: null,
hook: 'onFieldChanged',
widgetEdit: null,
enums: ['passport', 'id_card'],
},
{
field: 'file_0',
type: 'String',
isRequired: true,
isReadOnly: false,
value: null,
hook: 'onFieldChanged',
widgetEdit: filePicker,
},
{
field: 'comments_0',
type: 'String',
isRequired: false,
isReadOnly: false,
value: null,
hook: 'onFieldChanged',
widgetEdit: null,
},
];

const buildFormStates = (
options: Partial<ConstructorParameters<typeof FieldFormStates>[0]> = {},
) =>
new FieldFormStates({
actionName: 'KYC - Upload doc.',
actionPath: '/forest/actions/kyc-upload-doc',
collectionName: 'users',
httpRequester,
ids: ['1', '2'],
...options,
});

const loadForm = async () => {
const formStates = buildFormStates({ hooks: { load: true, change: ['onFieldChanged'] } });
httpRequester.query.mockResolvedValue({ fields: loadedFields, layout: [] });
await formStates.loadInitialState();
httpRequester.query.mockReset();

return formStates;
};

it('sends the data uri to the change hook and keeps what the agent answers', async () => {
const formStates = await loadForm();
httpRequester.query.mockResolvedValue({
fields: [
{ ...loadedFields[0], value: 'passport' },
{ ...loadedFields[1], value: pdfDataUri },
{ ...loadedFields[2], value: 'Passport received, expires 2031' },
],
layout: [],
});

await formStates.setFieldValue('file_0', pdf);

expect(httpRequester.query).toHaveBeenLastCalledWith({
method: 'post',
path: '/forest/actions/kyc-upload-doc/hooks/change',
body: {
data: {
attributes: {
collection_name: 'users',
changed_field: 'file_0',
ids: ['1', '2'],
fields: [
expect.objectContaining({ field: 'doc_type_0', value: null }),
expect.objectContaining({
field: 'file_0',
type: 'String',
value: pdfDataUri,
widgetEdit: filePicker,
}),
expect.objectContaining({ field: 'comments_0', value: null }),
],
},
type: 'custom-action-hook-requests',
},
},
});

expect(formStates.getFieldValues()).toEqual({
doc_type_0: 'passport',
file_0: pdfDataUri,
comments_0: 'Passport received, expires 2031',
});
expect(formStates.getField('file_0')?.getEffectiveTypeName()).toBe('File');
});

it('keeps the file picker widget on the ruby static-form path', async () => {
const formStates = buildFormStates({
hooks: { load: false, change: ['onFieldChanged'] },
fallbackFields: [
{
field: 'doc_type_0',
type: 'Enum',
isRequired: true,
hook: 'onFieldChanged',
enums: ['passport', 'id_card'],
},
{
field: 'file_0',
type: 'String',
isRequired: true,
hook: 'onFieldChanged',
widgetEdit: { name: 'file picker', parameters: {} },
},
{ field: 'comments_0', type: 'String', hook: 'onFieldChanged' },
],
});

await formStates.loadInitialState();

expect(httpRequester.query).not.toHaveBeenCalled();
expect(formStates.getField('file_0')?.getPlainField().widgetEdit).toEqual({
name: 'file picker',
parameters: {},
});
expect(formStates.getField('file_0')?.getEffectiveTypeName()).toBe('File');
expect(formStates.getField('doc_type_0')?.getEffectiveTypeName()).toBe('Enum');
expect(formStates.getField('comments_0')?.getEffectiveTypeName()).toBe('String');
});
});
});
71 changes: 71 additions & 0 deletions packages/agent-client/test/action-fields/field-getter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,75 @@ describe('FieldGetter', () => {
expect(new FieldGetter(createPlainField({ type: 'Json' })).getType()).toBe('Json');
});
});

describe('getEffectiveTypeName', () => {
const filePicker = { name: 'file picker', parameters: {} };

it('should map a String carrying the file picker widget to File', () => {
const fieldGetter = new FieldGetter(
createPlainField({ type: 'String', widgetEdit: filePicker }),
);

expect(fieldGetter.getEffectiveTypeName()).toBe('File');
expect(fieldGetter.getTypeName()).toBe('String');
});

it('should map a String list carrying the file picker widget to FileList', () => {
const fieldGetter = new FieldGetter(
createPlainField({ type: ['String'], widgetEdit: filePicker }),
);

expect(fieldGetter.getEffectiveTypeName()).toBe('FileList');
expect(fieldGetter.getTypeName()).toBe('StringList');
});

it('should leave a String without the file picker widget untouched', () => {
expect(new FieldGetter(createPlainField({ type: 'String' })).getEffectiveTypeName()).toBe(
'String',
);
expect(
new FieldGetter(
createPlainField({
type: 'String',
widgetEdit: { name: 'text area editor', parameters: {} },
}),
).getEffectiveTypeName(),
).toBe('String');
});

it('should leave a String untouched on the null widgetEdit the saas stores', () => {
const plainField = {
...createPlainField({ type: 'String' }),
widgetEdit: null,
} as unknown as PlainField;

expect(new FieldGetter(plainField).getEffectiveTypeName()).toBe('String');
});

it('should leave a non String type untouched even with the file picker widget', () => {
const fieldGetter = new FieldGetter(
createPlainField({ type: 'Number', widgetEdit: filePicker }),
);

expect(fieldGetter.getEffectiveTypeName()).toBe('Number');
});

it('should leave an Enum untouched', () => {
const fieldGetter = new FieldGetter(
createPlainField({ type: 'Enum', enums: ['passport', 'id_card'] }),
);

expect(fieldGetter.getEffectiveTypeName()).toBe('Enum');
expect(fieldGetter.getTypeName()).toBe('Enum');
});

it('should report a native File as File and a native File list as FileList', () => {
expect(new FieldGetter(createPlainField({ type: 'File' })).getEffectiveTypeName()).toBe(
'File',
);
expect(new FieldGetter(createPlainField({ type: ['File'] })).getEffectiveTypeName()).toBe(
'FileList',
);
});
});
});
Loading
Loading