Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
216 changes: 216 additions & 0 deletions packages/_example/src/forest/customizations/dvd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,220 @@ export default (collection: DvdCustomizer) =>
// Customize success message.
return resultBuilder.success(`Rental price increased`);
},
})
// Action with File upload, Multi-page form, and Dynamic fields
.addAction('Add DVD to Collection', {
scope: 'Global',
form: [
// Page 1: Basic DVD Information
{
type: 'Layout',
component: 'Page',
nextButtonLabel: 'Next: Media Files',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: '<h2>Add a New DVD</h2><p>Enter the basic information about the DVD.</p>',
},
{ type: 'Layout', component: 'Separator' },
{ label: 'Title', type: 'String', isRequired: true },
{
label: 'Genre',
type: 'Enum',
enumValues: [
'Action',
'Comedy',
'Drama',
'Horror',
'Sci-Fi',
'Documentary',
'Animation',
],
isRequired: true,
},
{
label: 'Release Year',
type: 'Number',
defaultValue: new Date().getFullYear(),
},
{
label: 'Is Special Edition',
type: 'Boolean',
widget: 'Checkbox',
defaultValue: false,
},
// Dynamic field: only visible when special edition is checked
{
label: 'Special Edition Name',
type: 'String',
if: ctx => ctx.formValues['Is Special Edition'] === true,
},
],
},

// Page 2: Media Files
{
type: 'Layout',
component: 'Page',
nextButtonLabel: 'Next: Details',
previousButtonLabel: 'Back',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: '<h2>Media Files</h2><p>Upload cover image and other media.</p>',
},
{ type: 'Layout', component: 'Separator' },
{
label: 'Cover Image',
type: 'File',
description: 'Upload the DVD cover image (JPEG, PNG)',
},
{
label: 'Trailer Video',
type: 'File',
description: 'Optional: Upload a trailer video',
},
{
type: 'Layout',
component: 'Row',
fields: [
{ label: 'Duration (minutes)', type: 'Number', isRequired: true },
{ label: 'Disc Count', type: 'Number', defaultValue: 1 },
],
},
{
label: 'Audio Languages',
type: 'StringList',
widget: 'CheckboxGroup',
options: [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'de', label: 'German' },
{ value: 'ja', label: 'Japanese' },
],
},
],
},

// Page 3: Additional Details
{
type: 'Layout',
component: 'Page',
nextButtonLabel: 'Create DVD',
previousButtonLabel: 'Back',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: '<h2>Additional Details</h2><p>Add any extra information.</p>',
},
{ type: 'Layout', component: 'Separator' },
{
label: 'Description',
type: 'String',
widget: 'TextArea',
},
{ label: 'Director', type: 'String' },
{
label: 'Main Cast',
type: 'StringList',
widget: 'TextInputList',
},
{
label: 'Rating',
type: 'Enum',
enumValues: ['G', 'PG', 'PG-13', 'R', 'NC-17'],
},
{
label: 'Price Category',
type: 'String',
widget: 'RadioGroup',
options: [
{ value: 'budget', label: 'Budget ($5-10)' },
{ value: 'standard', label: 'Standard ($10-20)' },
{ value: 'premium', label: 'Premium ($20-30)' },
{ value: 'collector', label: 'Collector ($30+)' },
],
defaultValue: 'standard',
},
// Dynamic field: only visible for Horror genre
{
label: 'Scare Level (1-10)',
type: 'Number',
if: ctx => ctx.formValues.Genre === 'Horror',
},
{
label: 'Extra Metadata',
type: 'Json',
widget: 'JsonEditor',
},
],
},
],
execute: async (context, resultBuilder) => {
const title = context.formValues.Title as string;
const genre = context.formValues.Genre as string;
const coverImage = context.formValues['Cover Image'] as {
name: string;
mimeType: string;
} | null;

// Log all form values for testing
console.log('DVD Form Values:', JSON.stringify(context.formValues, null, 2));

// In a real scenario, you would save the DVD to the database
// For now, just return a success message with the details
return resultBuilder.success(
`DVD "${title}" (${genre}) would be added to the collection!${
coverImage ? ` Cover: ${coverImage.name}` : ''
}`,
);
},
})
// Action that returns a file
.addAction('Generate DVD Label', {
scope: 'Single',
form: [
{
label: 'Label Format',
type: 'Enum',
enumValues: ['PDF', 'PNG', 'SVG'],
defaultValue: 'PDF',
},
{
label: 'Include Barcode',
type: 'Boolean',
widget: 'Checkbox',
defaultValue: true,
},
],
execute: async (context, resultBuilder) => {
const record = await context.getRecord(['title', 'rentalPrice']);
const format = context.formValues['Label Format'] as string;
const includeBarcode = context.formValues['Include Barcode'] as boolean;

// Generate a simple label content
const labelContent = `
DVD LABEL
=========
Title: ${record.title}
Price: $${record.rentalPrice}
${includeBarcode ? 'Barcode: ||||||||||||||||' : ''}
`.trim();

const mimeTypes: Record<string, string> = {
PDF: 'application/pdf',
PNG: 'image/png',
SVG: 'image/svg+xml',
};

return resultBuilder.file(
Buffer.from(labelContent),
`${record.title}-label.${format.toLowerCase()}`,
mimeTypes[format],
);
},
});
27 changes: 27 additions & 0 deletions packages/agent-client/src/domains/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,33 @@ export default class Action<TypingsSchema> {
});
}

/**
* Execute the action with support for file download responses.
* Returns either a JSON result or a file (buffer + metadata).
*/
async executeWithFileSupport(signedApprovalRequest?: Record<string, unknown>): Promise<
| { type: 'json'; data: Record<string, unknown> }
| { type: 'file'; buffer: Buffer; mimeType: string; fileName: string }
> {
const requestBody = {
data: {
attributes: {
collection_name: this.collectionName,
ids: this.ids,
values: this.fieldsFormStates.getFieldValues(),
signed_approval_request: signedApprovalRequest,
},
type: 'custom-action-requests',
},
};

return this.httpRequester.queryWithFileSupport<Record<string, unknown>>({
method: 'post',
path: this.actionPath,
body: requestBody,
});
}

async setFields(fields: Record<string, unknown>): Promise<void> {
for (const [fieldName, value] of Object.entries(fields)) {
// eslint-disable-next-line no-await-in-loop
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-client/src/domains/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ export default class Collection<TypingsSchema> extends CollectionChart {
);
}

async capabilities(): Promise<{
fields: { name: string; type: string; operators: string[] }[];
}> {
const result = await this.httpRequester.query<{
collections: { name: string; fields: { name: string; type: string; operators: string[] }[] }[];
}>({
method: 'post',
path: '/forest/_internal/capabilities',
body: { collectionNames: [this.name] },
});

const collection = result.collections.find(c => c.name === this.name);

return { fields: collection?.fields || [] };
}

async delete<Data = any>(ids: string[] | number[]): Promise<Data> {
const serializedIds = ids.map((id: string | number) => id.toString());
const requestBody = {
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-client/src/domains/relation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,16 @@ export default class Relation<TypingsSchema> {
query: QuerySerializer.serialize(options, this.collectionName),
});
}

async count(options?: SelectOptions): Promise<number> {
return Number(
(
await this.httpRequester.query<{ count: number }>({
method: 'get',
path: `/forest/${this.collectionName}/${this.parentId}/relationships/${this.name}/count`,
query: QuerySerializer.serialize(options, this.collectionName),
})
).count,
);
}
}
87 changes: 87 additions & 0 deletions packages/agent-client/src/http-requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,93 @@ export default class HttpRequester {
}
}

/**
* Execute a request that may return either JSON or a file (binary data).
* Returns the response with additional metadata to determine the response type.
*/
async queryWithFileSupport<Data = unknown>({
method,
path,
body,
query,
maxTimeAllowed,
}: {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
body?: Record<string, unknown>;
query?: Record<string, unknown>;
maxTimeAllowed?: number;
}): Promise<
| { type: 'json'; data: Data }
| { type: 'file'; buffer: Buffer; mimeType: string; fileName: string }
> {
try {
const url = new URL(`${this.baseUrl}${HttpRequester.escapeUrlSlug(path)}`).toString();

const req = superagent[method](url)
.timeout(maxTimeAllowed ?? 10_000)
.responseType('arraybuffer') // Get raw buffer for any response
.set('Authorization', `Bearer ${this.token}`)
.set('Content-Type', 'application/json')
.query({ timezone: 'Europe/Paris', ...query });

if (body) req.send(body);

const response = await req;

const contentType = response.headers['content-type'] || '';
const contentDisposition = response.headers['content-disposition'] || '';

// Check if this is a file download (non-JSON content type with attachment)
const isFile =
contentDisposition.includes('attachment') ||
(!contentType.includes('application/json') && !contentType.includes('text/'));

if (isFile) {
// Extract filename from Content-Disposition header
// Format: attachment; filename="report.pdf" or attachment; filename=report.pdf
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
let fileName = 'download';

if (fileNameMatch && fileNameMatch[1]) {
fileName = fileNameMatch[1].replace(/['"]/g, '');
}

return {
type: 'file',
buffer: Buffer.from(response.body),
mimeType: contentType.split(';')[0].trim(),
fileName,
};
}

// Parse as JSON
const jsonString = Buffer.from(response.body).toString('utf-8');
const jsonBody = JSON.parse(jsonString);

try {
return { type: 'json', data: (await this.deserializer.deserialize(jsonBody)) as Data };
} catch (deserializationError) {
// Log the failure - this is important for debugging schema mismatches
console.warn(
`[HttpRequester] Failed to deserialize JSON:API response, returning raw JSON. ` +
`Error: ${
deserializationError instanceof Error
? deserializationError.message
: String(deserializationError)
}`,
);

return { type: 'json', data: jsonBody as Data };
}
} catch (error: any) {
if (!error.response) throw error;
throw new Error(
JSON.stringify({ error: error.response.error as Record<string, string>, body }, null, 4),
);
}
}

async stream({
path: reqPath,
query,
Expand Down
Loading
Loading