Skip to main content
Code-based actions let you implement custom business logic in your back-end code. They provide full control over action behavior, forms, validation, and results. Use code-based actions when you need complex workflows, dynamic forms, external API integrations, file generation, or custom validation that goes beyond what no-code actions can do.
Custom action displayed in a table view

Basic structure

Here’s what a code-based action looks like:
agent.customizeCollection('companies', collection =>
  collection.addAction('Mark as Live', {
    scope: 'Single',
    description: 'Mark the company as live',
    form: [
      {
        type: 'Date',
        label: 'Live date',
        isRequired: true,
      },
      {
        type: 'String',
        label: 'Notes',
        widget: 'TextArea',
      },
    ],
    execute: async (context, resultBuilder) => {
      // Access form values
      const { 'Live date': liveDate, Notes: notes } = context.formValues;

      // Access selected record
      const company = await context.getRecord(['id', 'name']);

      // Perform business logic
      await markCompanyAsLive(company.id, liveDate, notes);

      // Return result
      return resultBuilder.success(`${company.name} is now live!`);
    },
  }),
);
include ForestAdminDatasourceCustomizer::Decorators::Action
include ForestAdminDatasourceCustomizer::Decorators::Action::Types

forest_agent.customize_collection('companies') do |collection|
  collection.add_action(
    'Mark as Live',
    BaseAction.new(
      scope: ActionScope::SINGLE,
      description: 'Mark the company as live',
      form: [
        {
          type: FieldType::DATE,
          label: 'Live date',
          is_required: true
        },
        {
          type: FieldType::STRING,
          label: 'Notes',
          widget: 'TextArea'
        }
      ]
    ) do |context, result_builder|
      # Access form values
      live_date = context.form_values['Live date']
      notes = context.form_values['Notes']

      # Access selected record
      company = context.get_record(['id', 'name'])

      # Perform business logic
      mark_company_as_live(company['id'], live_date, notes)

      # Return result
      result_builder.success("#{company['name']} is now live!")
    end
  )
end
@create_agent.collection :companies do |collection|
  collection.action 'Mark as Live', scope: :single do
    description 'Mark the company as live'

    form do
      field :live_date, type: :date, is_required: true
      field :notes, type: :string, widget: 'TextArea'
    end

    execute do
      # Access form values
      live_date = form_value(:live_date)
      notes = form_value(:notes)

      # Access selected record
      company = record(['id', 'name'])

      # Perform business logic
      mark_company_as_live(company['id'], live_date, notes)

      # Return result
      success "#{company['name']} is now live!"
    end
  end
end

Common examples

Send email

collection.addAction('Send Welcome Email', {
  scope: 'Single',
  form: [
    { type: 'String', label: 'Subject', isRequired: true },
    { type: 'String', label: 'Message', widget: 'TextArea', isRequired: true },
  ],
  execute: async (context, resultBuilder) => {
    const user = await context.getRecord(['email', 'name']);
    const { Subject, Message } = context.formValues;

    try {
      await sendEmail({
        to: user.email,
        subject: Subject,
        body: Message,
      });
      return resultBuilder.success(`Email sent to ${user.email}`);
    } catch (error) {
      return resultBuilder.error(`Failed to send email: ${error.message}`);
    }
  },
});
collection.add_action(
  'Send Welcome Email',
  BaseAction.new(
    scope: ActionScope::SINGLE,
    form: [
      { type: FieldType::STRING, label: 'Subject', is_required: true },
      { type: FieldType::STRING, label: 'Message', widget: 'TextArea', is_required: true }
    ]
  ) do |context, result_builder|
    user = context.get_record(['email', 'name'])
    subject = context.form_values['Subject']
    message = context.form_values['Message']

    begin
      send_email(
        to: user['email'],
        subject: subject,
        body: message
      )
      result_builder.success("Email sent to #{user['email']}")
    rescue => error
      result_builder.error("Failed to send email: #{error.message}")
    end
  end
)

Charge credit card

collection.addAction('Charge Credit Card', {
  scope: 'Single',
  form: [
    { type: 'Number', label: 'Amount', isRequired: true },
    { type: 'String', label: 'Description', isRequired: true },
  ],
  execute: async (context, resultBuilder) => {
    const customer = await context.getRecord(['stripeId', 'email']);
    const { Amount, Description } = context.formValues;

    try {
      const charge = await stripe.charges.create({
        amount: Amount * 100,
        currency: 'usd',
        customer: customer.stripeId,
        description: Description,
      });

      return resultBuilder.success('Charge successful', {
        html: `
          <p>Charged $${Amount} to ${customer.email}</p>
          <p>Transaction ID: ${charge.id}</p>
        `,
      });
    } catch (error) {
      return resultBuilder.error(`Charge failed: ${error.message}`);
    }
  },
});
collection.add_action(
  'Charge Credit Card',
  BaseAction.new(
    scope: ActionScope::SINGLE,
    form: [
      { type: FieldType::NUMBER, label: 'Amount', is_required: true },
      { type: FieldType::STRING, label: 'Description', is_required: true }
    ]
  ) do |context, result_builder|
    customer = context.get_record(['stripeId', 'email'])
    amount = context.form_values['Amount']
    description = context.form_values['Description']

    begin
      charge = Stripe::Charge.create(
        amount: (amount * 100).to_i,
        currency: 'usd',
        customer: customer['stripeId'],
        description: description
      )

      result_builder.success(
        'Charge successful',
        html: "<p>Charged $#{amount} to #{customer['email']}</p>
               <p>Transaction ID: #{charge.id}</p>"
      )
    rescue => error
      result_builder.error("Charge failed: #{error.message}")
    end
  end
)

Bulk status update

collection.addAction('Update Status', {
  scope: 'Bulk',
  form: [
    {
      type: 'Enum',
      label: 'New Status',
      enumValues: ['pending', 'approved', 'rejected'],
      isRequired: true,
    },
  ],
  execute: async (context, resultBuilder) => {
    const orders = await context.getRecords(['id']);
    const newStatus = context.formValues['New Status'];

    const ids = orders.map(o => o.id);
    await Order.update({ status: newStatus }, { where: { id: ids } });

    return resultBuilder.success(`Updated ${ids.length} orders to ${newStatus}`);
  },
});
collection.add_action(
  'Update Status',
  BaseAction.new(
    scope: ActionScope::BULK,
    form: [
      {
        type: FieldType::ENUM,
        label: 'New Status',
        enum_values: ['pending', 'approved', 'rejected'],
        is_required: true
      }
    ]
  ) do |context, result_builder|
    orders = context.get_records(['id'])
    new_status = context.form_values['New Status']

    ids = orders.map { |o| o['id'] }
    Order.where(id: ids).update_all(status: new_status)

    result_builder.success("Updated #{ids.length} orders to #{new_status}")
  end
)
collection.action 'Update Status', scope: :bulk do
  form do
    field :new_status, type: :enum, enum_values: ['pending', 'approved', 'rejected'], is_required: true
  end

  execute do
    orders = records(['id'])
    new_status = form_value(:new_status)

    ids = orders.map { |o| o['id'] }
    Order.where(id: ids).update_all(status: new_status)

    success "Updated #{ids.length} orders to #{new_status}"
  end
end

Generate report

collection.addAction('Generate Report', {
  scope: 'Global',
  generateFile: true,
  form: [
    { type: 'DateOnly', label: 'Start Date', isRequired: true },
    { type: 'DateOnly', label: 'End Date', isRequired: true },
  ],
  execute: async (context, resultBuilder) => {
    const { 'Start Date': startDate, 'End Date': endDate } = context.formValues;

    const data = await fetchReportData(startDate, endDate);
    const pdf = await generatePDF(data);

    return resultBuilder.file(
      pdf,
      `report-${startDate}-${endDate}.pdf`,
      'application/pdf'
    );
  },
});
collection.add_action(
  'Generate Report',
  BaseAction.new(
    scope: ActionScope::GLOBAL,
    is_generate_file: true,
    form: [
      { type: FieldType::DATE_ONLY, label: 'Start Date', is_required: true },
      { type: FieldType::DATE_ONLY, label: 'End Date', is_required: true }
    ]
  ) do |context, result_builder|
    start_date = context.form_values['Start Date']
    end_date = context.form_values['End Date']

    data = fetch_report_data(start_date, end_date)
    pdf = generate_pdf(data)

    result_builder.file(
      content: pdf,
      name: "report-#{start_date}-#{end_date}.pdf",
      mime_type: 'application/pdf'
    )
  end
)

Learn more

Forms

Form fields, validation, and dynamic behavior

Result types

All the ways to return feedback to users

Context & scope

Understanding scopes and the context object

Related data invalidation

Refresh related data after actions